- 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.
17 KiB
Backlog
Informal list of work items that aren't yet scheduled. Convert to GitHub issues if/when a remote is added.
Speculative or longer-term ideas live in ideas.md.
Foundations
Account & session lifecycle
- JWT refresh tokens. Today's tokens are signed with HS256 and have no refresh path; they expire and the user gets bounced to login. Add refresh tokens with rotation + revocation list.
- Account deletion / data export. GDPR-style request handling. Family-scoped: deleting a user shouldn't delete the family's data, but should redact PII.
- Member removal (tombstone) flow. When a family admin removes a member, keep the
CheckedByUserId/RemovedByUserIdFKs intact for history but tombstone the user so they can no longer authenticate, and ensureOnTokenValidatedrejects tokens for removed users.
Real-time conflict resolution
- The deferred SignalR-versioning plan in memory addresses connect-window races. It does not address edit conflicts (two family members renaming the same store, unchecking-then-checking the same item simultaneously).
- Decide a strategy per resource: last-write-wins is fine for
IsCheckedtoggles; for renames and other field edits, optimistic concurrency with aRowVersion(xmin) is safer. - SignalR pushes already broadcast updates; conflict surfaces server-side at SaveChanges and needs a retry/merge story per endpoint.
Offline shopping (PWA)
- Service worker exists but no offline behavior is tracked. Killer use case: standing in the freezer aisle with bad signal and needing to check items off.
- Strategy:
- Cache the active list and its product/section data on first load.
- Queue mutations (toggle checked, soft-remove, add item) to IndexedDB while offline.
- On reconnect, replay queued mutations against the API; merge conflicts using the strategy above.
- SignalR reconnect already exists — needs to play nicely with replay (don't double-apply local-then-server events).
- Visible offline indicator + "X pending sync" badge so users know what's local-only.
Backup & restore strategy
- Single-tenant today, so a misconfigured volume = one family's loss. With multi-tenancy, you're custodian of all families' data — turns this from "good practice" into "non-negotiable."
- Decide: Postgres logical backups (
pg_dump) on a schedule, written to an external location (S3-compatible / cloud blob). Include retention policy (daily for N days, weekly for M weeks). - Restore runbook documenting how to recover, ideally rehearsed on a fresh environment.
UX foundations
Onboarding & empty states
- A brand-new family logs in to nothing — no stores, no lists, no recipes. Currently the lists page just renders empty.
- First-run experience: a guided "Add your first store → create your first list" flow, or at minimum well-designed empty states with primary CTAs on each tab.
- Coordinate with the "block create-list when no stores exist" item — same surface, different angle.
Accessibility pass
- A family app gets used one-handed in noisy stores. Targets: WCAG AA contrast, large hit targets (44×44 minimum on touch), keyboard nav for desktop, screen-reader labels on icon-only buttons (the
📋📖🏪nav uses emoji + text — confirm the text is the accessible name, not the emoji). - One-time audit + fixes; ongoing checklist for new components.
Item reorder within section
ShoppingListItem.SortOrderexists on the entity. Verify whether the UI exposes drag-to-reorder; if not, add it. Same drag-and-drop pattern recommended for store sections lands here too.
Product 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.
Shipped
- Entities & schema:
Product(global, read-only),FamilyProduct(family-owned),FamilyProductOverride(per-family edit of a global product, composite PK onFamilyId+ProductId), andProductStoreSection(per-store-per-product section memory).AllowedUnitCategorieson each so the unit dropdown can be narrowed per product. - API (
/api/products): search with transparent override merge (returnsisOverriddenper row);POSTto create aFamilyProduct;PUT /family/{id}andPUT /global/{id}(the latter upserts aFamilyProductOverride);DELETE /family/{id}andDELETE /global/{id}/overrideto 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.
ProductStoreSectionwrite 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;onItemProductChangeon 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 byCatalogSeederon startup (idempotent onName).
Remaining
- "Add '' 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
FamilyProductfrom the same dropdown is still open. - Seed expansion to ~2–3k curated items. Current seed file has ~50 entries. Growth is a data exercise, not a code one — keep
products.jsonthe source of truth, keep the seeder idempotent onName. - 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-idon each importedProductfor 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
FamilyProductcreations 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.
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).
RecipeIngredientcarries an optionalProductId; 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
FamilyProductOverriderow. Per-field reset was considered and rejected as over-engineering for v1. - Family-product delete is unconditional. FKs from
ShoppingListItem/RecipeIngredientareSET NULL,ProductStoreSectioncascades — so deleting a family product is safe; linked rows keep their text and lose the link.
Shopping list items
Structured quantities + unit of measure
Replace free-form Quantity strings with a structured (Quantity, UnitOfMeasure) pair. Both ShoppingListItem and RecipeIngredient use the same model.
Unit catalog
- Global
UnitOfMeasuretable — curated, app-wide base catalog. - Family-scoped
FamilyUnitOfMeasuremirrors theFamilyProductpattern: families can add their own units (we won't capture every possible unit upfront), visible only to that family. - The "effective unit catalog" exposed to a family = global ∪ that family's custom units (same merge pattern as products).
- Fields:
IdSingularName— full description, singular ("each", "pound", "box")PluralName— full description, plural ("each", "pounds", "boxes")Abbreviation— short form ("ea", "lb", "bx")Category(enum:Weight | Volume | Count | Packaging)IsBase— canonical-in-category flag, reserved for future conversionsSortOrderFamilyUnitOfMeasureaddsFamilyIdand otherwise mirrors the same shape.
- Display rules of thumb (pin during UX work):
- Compact contexts (list rows, ingredient rows): use
Abbreviation("2 lb bananas", "1 ea milk"). - Expanded contexts (item detail, edit forms): use
SingularName/PluralNameand pluralize by quantity. - Always show the abbreviation in the UoM dropdown alongside the full name (e.g. "Pound (lb)") so users can scan either.
- Compact contexts (list rows, ingredient rows): use
- Validation:
SingularName,PluralName, andAbbreviationare all required on the unit row. Uniqueness — at minimum,Abbreviationmust be unique within the effective catalog (global + a given family's customs) so list items render unambiguously. - Promotion candidate: family-added units that show up across many families are flagged for editorial review, same pipeline as the product catalog.
- Suggested seed (revise during design):
- Count: each, dozen
- Weight: oz, lb, g, kg
- Volume: tsp, tbsp, cup, fl oz, pint, quart, gallon, ml, L
- Packaging: box, bag, case, bottle, can, jar, pack, bunch, head, loaf, carton, roll
- Pluralization handled by the
PluralLabelfield — render "1 box" vs "2 boxes" correctly without ad-hoc string logic.
Optionality + defaults
- Recipes: both
QuantityandUnitOfMeasureIdare required onRecipeIngredient. A recipe step needs to know how much and in what unit. For free-text approximations like "salt to taste", use theIsApproximate+QuantityNoteescape hatch noted further down — that path lets the row satisfy the required fields with sentinel values while still rendering as approximate text. - Shopping lists: users don't have to enter a quantity or a unit on
ShoppingListItem. "Bananas" without specifying anything is valid input. - List-only defaults: if a shopping list item is saved without these fields, persist
Quantity = 1andUnitOfMeasureId = <id of "each">. UI renders implicitly — "Bananas" rather than "1 each Bananas" — but the underlying row always has structured values, which keeps the model uniform with recipes and downstream features (catalog stats, recipe→list copy) work without nullable-handling branches. - Implication: "each" must exist in the seed unit catalog and have a stable, well-known id (or lookup-by-code) the backend can rely on as the default.
Product ↔ unit relationship (many-to-many, single per instance)
ProductAllowedUnitjoin:(ProductId, UnitOfMeasureId). Says which units this product can be sold/measured in.- Example: "Nails" → allowed units =
{box, bag, lb}. The user can buy a box, a bag, or a pound of nails — but a given list item is exactly one of those. - Each
ShoppingListItemandRecipeIngredientcarriesQuantity (decimal)andUnitOfMeasureId (FK)— single unit per row. Validation: the chosenUnitOfMeasureIdmust be in the product'sProductAllowedUnitset (when the row references a product). Free-form rows (noProductId) accept any unit. - When the catalog doesn't yet know a unit is valid for a product, allow the user to add it on the fly — that becomes a family-scoped extension to
ProductAllowedUnit(mirrors how family overrides work for other product fields).
API & migration
Quantitybecomesdecimal?on bothShoppingListItemandRecipeIngredient; existing stringQuantityonRecipeIngredientmigrates via best-effort parse (numeric prefix → quantity, trailing word → unit lookup, residue → notes). Anything that can't be parsed stays in aQuantityNotestring./api/unitsendpoint to list available units (cached on the client).- Product create/update accepts a list of allowed unit ids.
UX
- Quantity input = numeric field + unit dropdown. Unit dropdown is filtered to the product's allowed units (or all units when no product is selected). Default to the most-used unit for that product based on family history (longer-term polish).
- Display: render with the correct singular/plural label — "1 box of nails", "3 boxes of nails", "1.5 lbs of bananas".
Out of scope for v1
- Cross-unit conversions (lb ↔ oz, cup ↔ ml). Don't auto-merge "2 lbs apples" + "1 lb apples" — show as two rows. Pure-volume and pure-weight conversions are doable later via the
IsBaseflag and a conversion factor; weight↔volume requires per-product density and is out of scope indefinitely. - Unit locale preferences (display lb vs kg by user locale). Store what was entered.
Open questions
- How aggressively do we pre-populate
ProductAllowedUnitfor the curated catalog? At minimum, common-sense defaults per category (produce → lb/each; dairy → gallon/quart/oz; etc.). Could ship with sensible defaults and let families extend. - "A few", "to taste", "some" — these are real recipe quantities that don't fit
(decimal, unit). Probably modeled as a specialIsApproximateflag with an optionalQuantityNoterather 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, 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
SortOrdernumbers manually.
Recipes
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 (2–3 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
Instructionsas 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
Store types / categorization
- Add a
type(orcategory) toStoreso users can classify each one: grocery (Publix, Kroger), home improvement (Lowes, Home Depot), big box (Sam's, Costco), etc. - Backend: new column on
Store, migration, exposed in create/update payloads. Likely a fixed enum maintained by the app rather than user-defined, but confirm. - Frontend: type selector in the add/edit store form. Consider grouping or filtering the stores list by type once the dataset grows.
- Open questions:
- Fixed enum of types vs. user-extensible list? (Recommend fixed to start — easier to reason about, matches how shopping lists are likely scoped per store.)
- Starter set of types? Suggested: Grocery, Home Improvement, Big Box, Pharmacy, Specialty, Other.
- Does store type affect anything beyond display (e.g., filtering recipes/ingredients to grocery-only stores)?