88c24b03ca
Introduces a runes-based toast module (`$lib/toast.svelte.ts`) with success/info/warning/error variants, auto-dismiss, optional action button, plus a `Toaster` viewport mounted in the root layout. Migrates the lone `alert(e.message)` call site (stores delete) to `toast.error()`. Backlog updated to remove the now-completed foundational item and rewrite dependent items to reference the shipped API. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
241 lines
24 KiB
Markdown
241 lines
24 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
|
||
|
||
### 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.
|
||
|
||
### 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 (~2–3k 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.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.
|
||
|
||
## 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 surfaces the backend message via `toast.error()`. Replace with the new confirmation modal that also surfaces this error inline (or as a toast on confirm-action failure).
|
||
- 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.error()` (e.g. "A store named 'Kroger' already exists") rather than failing silently.
|
||
- Same pattern likely affects PUT (rename to an existing name).
|