Files
YesChef/BACKLOG.md
T
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

208 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` / `RemovedByUserId` FKs intact for history but tombstone the user so they can no longer authenticate, and ensure `OnTokenValidated` rejects 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 `IsChecked` toggles; for renames and other field edits, optimistic concurrency with a `RowVersion` (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.SortOrder` exists 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
### 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.
#### 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.
#### 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`.
#### 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.
## 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 `UnitOfMeasure` table — curated, app-wide base catalog.
- Family-scoped `FamilyUnitOfMeasure` mirrors the `FamilyProduct` pattern: 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:
- `Id`
- `SingularName` — 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 conversions
- `SortOrder`
- `FamilyUnitOfMeasure` adds `FamilyId` and 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` / `PluralName` and pluralize by quantity.
- Always show the abbreviation in the UoM dropdown alongside the full name (e.g. "Pound (lb)") so users can scan either.
- Validation: `SingularName`, `PluralName`, and `Abbreviation` are all required on the unit row. Uniqueness — at minimum, `Abbreviation` must 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 `PluralLabel` field — render "1 box" vs "2 boxes" correctly without ad-hoc string logic.
#### Optionality + defaults
- **Recipes:** both `Quantity` and `UnitOfMeasureId` are **required** on `RecipeIngredient`. A recipe step needs to know how much and in what unit. For free-text approximations like "salt to taste", use the `IsApproximate` + `QuantityNote` escape 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 = 1` and `UnitOfMeasureId = <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)
- `ProductAllowedUnit` join: `(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 `ShoppingListItem` and `RecipeIngredient` carries `Quantity (decimal)` and `UnitOfMeasureId (FK)` — single unit per row. **Validation: the chosen `UnitOfMeasureId` must be in the product's `ProductAllowedUnit` set** (when the row references a product). Free-form rows (no `ProductId`) 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
- `Quantity` becomes `decimal?` on both `ShoppingListItem` and `RecipeIngredient`; existing string `Quantity` on `RecipeIngredient` migrates via best-effort parse (numeric prefix → quantity, trailing word → unit lookup, residue → notes). Anything that can't be parsed stays in a `QuantityNote` string.
- `/api/units` endpoint 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 `IsBase` flag 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 `ProductAllowedUnit` for 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 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).
- **Section drag-to-reorder** in the store edit UI — section walk order matters, but reordering today only works by editing `SortOrder` numbers manually.
### Auto-assign section from product
When a user picks a product from the typeahead on the shopping list add form, pre-populate the section dropdown with that product's known section for the current store — rather than leaving it as "Uncategorized".
This is the "per-store ingredient memory" item above, stated from the user's perspective: choosing "Spaghetti" should already know it belongs in Pasta/Dry Goods at this store.
**Data model:** `ProductStoreSection` is already sketched in the product catalog section above — `(FamilyId, ProductId | FamilyProductId, StoreId, StoreSectionId)`. That table is the source of truth.
**Write path:** when a user manually changes the section on a list item whose product is linked, offer to remember that choice (or auto-remember silently after N uses). Write to `ProductStoreSection`.
**Read path:** on product selection in the add-item form, query the effective section for `(productId, storeId)` and pre-select it in the section dropdown. User can still override.
**Dependencies:** requires the product catalog entity to be in place (`ProductId` on `ShoppingListItem`, product typeahead already wired). The `ProductStoreSection` table and its endpoints are net-new work.
**Scope note:** the section pre-fill should be scoped to the family's own memory (`ProductStoreSection` rows they've created), not a global default — different families organize differently.
## 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 (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
### Store types / categorization
- Add a `type` (or `category`) to `Store` so 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)?