f38530cf81
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>
180 lines
18 KiB
Markdown
180 lines
18 KiB
Markdown
# 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
|
||
|
||
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 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)`.
|
||
- **Seed data:** ~50 hand-curated common-groceries items in `Data/Seed/products.json`, applied by `CatalogSeeder` on startup (idempotent on `Name`).
|
||
|
||
#### Remaining
|
||
- **Auto-assign section from product on add.** The write path remembers `(product, store) → section` and the backend has a helper that reads it back, but the add-item form doesn't call it on product pick yet. Described in detail in *Auto-assign section from product* further down.
|
||
- **"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 ~2–3k 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.
|
||
|
||
#### 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
|
||
|
||
### 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.
|
||
|
||
**State of play:** `ProductStoreSection` is shipped — table, indexes, and the write path that records `(family, store, product) → section` whenever an item with a product link is saved with a chosen section. A backend helper in `ShoppingListEndpoints` already reads the effective section back. **The missing piece is the frontend:** `onItemProductChange` in `lists/[id]/+page.svelte` doesn't call the read path on product pick yet, so the section dropdown still defaults to "Uncategorized" until the user sets it.
|
||
|
||
**To finish:** expose the lookup via an endpoint (`GET /api/products/{id}/section?storeId=...` or roll it into the typeahead response when a `storeId` is supplied), call it on `onItemProductChange`, and pre-select the returned section. Same treatment for the recipe → list copy path so adding a recipe to a list lands ingredients in the right sections.
|
||
|
||
**Scope note:** the section pre-fill is family-scoped 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 (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 `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)?
|
||
|