Files
YesChef/BACKLOG.md
T
Josh Rogers 6780fb366e Add BACKLOG.md and ideas.md from product exploration session
Captures the design direction from a multi-feature exploratory session:
- Multi-tenant Family epic (decided)
- Product + unit catalogs with family-scoped overrides and free-form additions
- Per-store sections / departments with grouped list UI
- Structured quantities + UoM (required on recipes, optional on lists with sensible defaults)
- Pick-up vs remove distinction for shopping list items
- Add-recipe-to-list flow
- Stores polish: types, confirm-before-delete, duplicate-name bug, toast component
- Foundational items (auth lifecycle, conflict resolution, offline PWA, backup/restore, onboarding, accessibility)

ideas.md holds longer-term explorations (recipe URL import, product images,
item notes, meal planning, observability).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:32:56 -05:00

251 lines
25 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
### Family entity + multi-tenant migration (epic)
Prerequisite for the product catalog and unit catalog work — both rely on a `FamilyId` for scoping. Tracked separately so it doesn't get buried inside the catalog item.
- Introduce a `Family` entity. Every existing user/store/list/recipe/section/item row gets a `FamilyId` and an API filter applies it everywhere.
- Per-family invite codes replace the env-var `FAMILY_CODE`. Decide: rotating? expiring? single-use? Recommend regenerable per-family codes that admins control.
- Member roles: at minimum `Admin` vs `Member`. Admins can manage stores, archive lists, invite/remove members, regenerate the family code. Members can do everyday list/recipe work.
- Member removal flow: what happens to their `CreatedByUserId`, `CheckedByUserId` references? Likely keep the FK (history) but tombstone the user record so they can't log in.
- **Bootstrap migration for the existing single-family deployment:** at upgrade time, auto-create one `Family` from the current `FAMILY_CODE` env value, assign all existing rows to it, and migrate the env var off. Flag explicitly in the runbook so deployment isn't a surprise.
- Auth changes: JWT carries `FamilyId` claim. `OnTokenValidated` rejects tokens whose user has been removed from the family.
### Toast / notification component (foundational)
- App has no toast system today; current error UX is `alert()` on `stores/+page.svelte` delete failures. Several tracked items depend on this:
- Duplicate store name → toast (Stores section)
- Block create-list when no stores → toast (Lists section)
- Confirm-before-delete error surfacing (Stores section)
- Build a small reusable Svelte component (success / info / warning / error variants, auto-dismiss with timeout, optional action button for "Undo"), expose via `$lib/toast.ts`.
- Migrate existing `alert(e.message)` call sites as part of landing it.
### Account & session lifecycle
- **Password reset.** Currently no flow, no email infrastructure. Required before multi-family launches publicly. Needs SMTP config, email template, single-use token table, rate limiting on the reset endpoint.
- **JWT refresh tokens.** Today's tokens are signed with HS256 and (per `Program.cs`) likely 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.
- **Rate limiting on `/api/auth/register` and `/api/auth/login`.** Currently unlimited — with multi-family this becomes a real abuse vector.
### 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.
#### Architectural prerequisite — multi-tenant model
- Today the app is single-tenant per deployment (one `FAMILY_CODE` per deployment, no `Family` entity). The "isolated per family" requirement implies a `Family` entity and family-scoped tenant boundaries.
- **Decision (2026-05-06):** going with option (a) — one deployment, many families. Existing `FAMILY_CODE` becomes a per-family invite mechanism (each family has its own code), and cross-family promotion analytics become meaningful.
- Either way: introduce `Family` entity, every existing user/store/list/recipe/section row gets a `FamilyId`, and an API filter applies it everywhere. This is a substantial migration — separate epic.
#### 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 item actions
### Distinguish "picked up" from "removed"
- Today `ShoppingListItem` only has `IsChecked` (+ `CheckedByUserId`). The UI conflates two genuinely different intents:
- **Picked up** — the item was acquired at the store. Belongs to the shopping history. Should remain visible (greyed out / struck through) so the rest of the family knows it was grabbed and by whom. Reversible (un-check). This is what `IsChecked` already represents.
- **Removed** — the item shouldn't be on the list at all (typo, changed mind, duplicate, decided to skip). Disappears from the active list. Probably soft-deleted so it can be undone within the session.
- Schema: add `RemovedAt` (nullable timestamp) and `RemovedByUserId` (nullable FK) to `ShoppingListItem`. Active list = items where `RemovedAt IS NULL`. Keep the row so we can track "who removed what" and offer undo.
- API: a separate `DELETE /api/lists/{id}/items/{itemId}` for soft-remove (sets `RemovedAt`), distinct from the existing toggle-checked endpoint. A hard-purge can run on list archive.
- UX:
- Tap (or check the checkbox) → marks picked up. Item stays visible, struck through, with the picker's name.
- Swipe-left / explicit trash icon → removes. Show a snackbar with "Undo" for ~5 seconds.
- Don't ever surface a destructive remove behind the same gesture as pick-up — too easy to lose data.
- Reporting: when a list is completed/archived, "what got bought" = items with `IsChecked=true AND RemovedAt IS NULL`. This is also the natural input for the future per-store ingredient-section memory and any "frequently bought" suggestions.
## 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 / departments for items
- Each store has its own section layout (Produce, Meat/Seafood, Condiments, Frozen, Bakery, Dairy, etc.) — sections are properties of the store, not global.
- Schema:
- New `StoreSection` entity: `Id`, `StoreId`, `Name`, `SortOrder` (controls walk order through the store). Unique on `(StoreId, Name)`.
- `ShoppingListItem.SectionId` nullable FK to `StoreSection` — null means "uncategorized".
- API: CRUD for sections under a store (`/api/stores/{id}/sections`). Item create/update accepts an optional `sectionId`.
- **List view MUST group items by section in the UI** — this is the core UX payoff. Items render under collapsible section headers, ordered by the store's section `SortOrder` (so the list reads top-to-bottom in walk order through the store). An "Uncategorized" bucket holds items without a section (place at the end).
- UX for assigning section: dropdown on the item row, scoped to the list's store's sections.
- Open questions:
- **Seed defaults?** When a new store is created, should we seed a default section list (Produce, Meat/Seafood, Dairy, Bakery, Frozen, Pantry, Condiments, Beverages, Other) that the user can edit, or start empty? Recommend seeding — saves setup friction.
- **Per-store ingredient memory?** Should the app remember "last time `Bananas` was bought at Kroger it was in Produce" and auto-assign on next add? Big UX win, but adds an `IngredientSection` mapping table per store. Probably v2.
- **Recipes → sections?** When pulling recipe ingredients into a list, do we try to map them to sections? Same answer as above — depends on whether we add the per-store ingredient memory.
- **Section reordering UI?** Drag-to-reorder on the store edit page is the natural fit since section walk order matters.
## Lists
### Add recipe to shopping list
- From a recipe page, "Add to shopping list" → user picks an existing list (filtered to lists for the same family). Each `RecipeIngredient` becomes a `ShoppingListItem`:
- If the ingredient has a `ProductId`, the new list item references the same product → its store-section assignment for that list's store auto-applies, and the item lands in the right group.
- If the ingredient is free-form text, the new list item carries the text in `Name` with a null `SectionId` (lands in "Uncategorized").
- `Quantity` copies through.
- `RecipeId` on the new list item links back to the source recipe (already supported by `ShoppingListItem.RecipeId`).
- UX: confirmation step showing the resolved items + their target sections, with the option to deselect anything before adding.
- Edge case: if the same product is already on the list (and unchecked), prompt to merge quantities vs. add a duplicate row. Probably default to add-duplicate to keep things simple.
- Depends on: product catalog feature, sections feature, quantities on list items.
### 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 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.
- Belongs with the toast-component work above.
## 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)?
### Confirm-before-delete + block delete when in use
- Add a modal confirmation prompt before deleting a store (currently one click → gone, no undo).
- Disallow deletion of a store that has any active shopping lists associated with it.
- Backend status: `StoreEndpoints.cs` DELETE already returns 400 with `{error: "Store has shopping lists. Remove them first."}` if any list references the store. Consider switching to 409 Conflict for semantic correctness.
- Frontend status: `stores/+page.svelte` `deleteStore` currently shows the backend message via `alert()`. Replace with the new confirmation modal that also surfaces this error inline.
- Open question: what counts as "active"? Backend currently blocks on *any* list. Decide whether archived/completed lists should still block deletion.
### Duplicate store name → 500 + silent frontend (bug)
- Adding a store with a name that already exists triggers the unique constraint on `Store.Name`, which leaks out as a 500 from `StoreEndpoints.cs` POST (no exception handling).
- Frontend `addStore` in `stores/+page.svelte` swallows the error — the form just sits there with no feedback.
- Fix: backend should pre-check (or catch the unique-violation) and return 409 with a helpful message; frontend should display a **toast** (e.g. "A store named 'Kroger' already exists") rather than a silent failure or a blocking `alert()`.
- Same pattern likely affects PUT (rename to an existing name).
- Note: app does not currently have a toast system. This work probably needs a small reusable toast component before the duplicate-name handling can land — worth adopting it across the app (the existing `alert(e.message)` calls on delete failures should also move to toasts).