Phase 5 cleanup
This commit is contained in:
parent
5536acd67d
commit
76a516a417
136 changed files with 6322 additions and 1985 deletions
247
docs/frontend-redesign-plan.md
Normal file
247
docs/frontend-redesign-plan.md
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
# Frontend Redesign Plan — Aligning with `docs/design` Mockups
|
||||
|
||||
This plan describes how to evolve `packages/web` from the current minimal Tailwind UI to match the editorial, warm-neutral design defined in `docs/design/MeshiTrack.html` and the screenshots under `docs/design/screenshots/`.
|
||||
|
||||
The mockups are a React (UMD + Babel) prototype using a CSS-token system. We will port the **visual language and information architecture** into the existing **Next.js App Router + Tailwind v4** project, without dragging the prototype's runtime (React UMD, global `window.*` modules, inline data) into production.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goals & Non-Goals
|
||||
|
||||
### Goals
|
||||
|
||||
- Match the visual language of the mockups: typography (Fraunces / Inter Tight / JetBrains Mono), warm-neutral palette, sage brand, generous whitespace, soft shadows, rounded cards.
|
||||
- Reproduce the **app shell**: persistent left sidebar with grouped navigation + sticky topbar with title/subtitle, search, theme toggle, notifications.
|
||||
- Reproduce the **page archetypes** shown in the mockups: editorial "hero" header, multi-card grid with consistent `mt-card` style, status pills, supply bars, mini SVG charts.
|
||||
- Support light/dark themes and an accent switcher (sage / cobalt / terracotta / graphite) via CSS custom properties.
|
||||
- Keep accessibility, responsive layout, and SSR-friendliness intact.
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- Do not import the prototype's `data.jsx` mock data into production.
|
||||
- Do not adopt the prototype's `window.*` global module pattern or Babel-in-browser runtime.
|
||||
- Do not redesign data flows or API contracts; only the presentation layer changes.
|
||||
- Do not block on building every page in the mockup; food-domain pages (Phases 5-9) will reuse the system once the medicine pages are migrated.
|
||||
|
||||
---
|
||||
|
||||
## 2. Gap Analysis
|
||||
|
||||
| Area | Current state (`packages/web`) | Target (mockups) |
|
||||
| ------------------------------------------------------------------------------------------------------ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | --------------------------------- |
|
||||
| Theme tokens | Tailwind v4 `@theme` with a green ramp only | Full token set: warm neutrals, brand sage, status, viz palette, radii, shadows, fonts; light + dark |
|
||||
| Typography | System sans only | Fraunces (display), Inter Tight (UI), JetBrains Mono (numerics/keys), tabular-nums utility |
|
||||
| Sidebar | Flat list, 3 links, no groups, no badges | Grouped (`Medicines` section), icons, active state, badges, footer with avatar/role |
|
||||
| TopBar | Household chip + avatar | Breadcrumbs, title + subtitle, search with `⌘K`, notifications, theme toggle |
|
||||
| Cards | Ad-hoc Tailwind cards | Reusable `Card`, `CardHeader`, `Pill`, `Button` (`primary`/`ghost`), supply bar, ring chart, sparkbar |
|
||||
| Dashboard | 2 link cards | Hero greeting + ring, Today's schedule, Running low, Spending bars, Days-of-supply, Pending orders, Recent activity |
|
||||
| Cabinet / Schedule / Regimens / Organizer / Library / Refills / Purchases / Prices / Stores / Activity | Mostly placeholder grids/tables | Editorial layouts with status chips, swatches, supply bars (see screenshots) |
|
||||
| Theming | None | `data-theme="light | dark"`on`<html>`, accent CSS vars |
|
||||
| Icons | None defined centrally | Inline SVG icon set (`Icon.jsx` in mockup) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Target Architecture
|
||||
|
||||
### 3.1 Design tokens (Tailwind v4 `@theme` + CSS vars)
|
||||
|
||||
- Replace the current `@theme` block in [packages/web/src/styles/globals.css](packages/web/src/styles/globals.css) with the full token set from [docs/design/styles/tokens.css](docs/design/styles/tokens.css):
|
||||
- Color tokens exposed both as CSS vars (`--bg`, `--ink`, `--brand`, ...) **and** as Tailwind theme colors (`--color-bg`, `--color-ink`, ...) so utilities like `bg-bg`, `text-ink`, `border-border` work.
|
||||
- Radii (`--r-xs`..`--r-xl`) and shadows (`--shadow-sm/md/lg`).
|
||||
- Font family vars + load Fraunces / Inter Tight / JetBrains Mono via `next/font/google` in [packages/web/src/app/layout.tsx](packages/web/src/app/layout.tsx) and bind them to the `--font-*` vars.
|
||||
- Add `[data-theme='dark']` overrides in `globals.css` (copy from tokens.css).
|
||||
- Add small global utility classes used by the mockup: `.mono`, `.serif`, `.num` (tabular-nums) — keep names so mockup snippets can be lifted with minimal edits.
|
||||
|
||||
### 3.2 Theme + accent provider
|
||||
|
||||
- New `ThemeProvider` (client component) that:
|
||||
- Persists `theme` (`light|dark`) and `accent` (`sage|cobalt|terracotta|graphite`) in `localStorage`.
|
||||
- Sets `data-theme` on `document.documentElement` and writes the accent's `--brand`, `--brand-deep`, `--brand-soft`, `--brand-soft-ink`, `--viz-1` CSS vars (logic ported from `docs/design/MeshiTrack.html` `useEffect`).
|
||||
- Avoids FOUC by emitting a tiny inline script in `app/layout.tsx` that reads `localStorage` and applies `data-theme` before hydration.
|
||||
- Expose a `useTheme()` hook for the topbar toggle.
|
||||
|
||||
### 3.3 Component library (`packages/web/src/components/ui/`)
|
||||
|
||||
Port the mockup's reusable primitives as typed React components:
|
||||
|
||||
- `Icon` — single component over an inline SVG map (`dashboard`, `cabinet`, `clock`, `list`, `calendar`, `pill`, `refresh`, `truck`, `tag`, `store`, `trend`, `settings`, `search`, `bell`, `sun`, `moon`, `chev`, `arrow`, `check`, `injection`, `capsule`).
|
||||
- `Card`, `CardHeader` (title + sub + right-side action slot).
|
||||
- `Button` with `variant: 'primary' | 'ghost' | 'danger'` and optional `size`.
|
||||
- `Pill` (status chip) with `tone: 'ok' | 'warn' | 'danger' | 'info' | 'neutral'`.
|
||||
- `SupplyBar`, `Ring` (SVG progress ring), `SparkBars` (monthly bars), `Sparkline`.
|
||||
- `Kbd`, `IconButton`, `Avatar`, `SearchInput`, `Breadcrumbs`.
|
||||
|
||||
These live in `packages/web/src/components/ui/` and are pure presentational components (no data fetching, no `'use client'` unless they need state — most do not).
|
||||
|
||||
### 3.4 App shell
|
||||
|
||||
Rewrite the layout primitives:
|
||||
|
||||
- [packages/web/src/components/layout/Sidebar.tsx](packages/web/src/components/layout/Sidebar.tsx):
|
||||
- Brand block (logo + wordmark + household sub-line — `household.name` instead of mock "Red Panda Den").
|
||||
- Grouped nav from a `NAV` array mirroring the mockup's structure but driven by `next/navigation`'s `usePathname` for active state.
|
||||
- Footer with `Avatar` + `displayName` + role from `useApi()`.
|
||||
- Badges (e.g. low-supply count, pending refills) wired to real SWR fetchers (Phase 2 — initially render the slot but optional).
|
||||
- [packages/web/src/components/layout/TopBar.tsx](packages/web/src/components/layout/TopBar.tsx):
|
||||
- Accept `title`, `subtitle`, `crumbs`, `actions` props. Provided either via a `<PageHeader>` component each route renders, **or** via a Zustand/Context "page header" store updated from each page (recommended: a server-component `PageHeader` slot rendered above the page body — simpler, no client state).
|
||||
- Search input (visual only at first; `⌘K` palette is a follow-up).
|
||||
- Theme toggle wired to `useTheme()`.
|
||||
- Notifications bell (visual only initially).
|
||||
- [packages/web/src/app/(dashboard)/layout.tsx](<packages/web/src/app/(dashboard)/layout.tsx>):
|
||||
- Switch root container to the mockup's grid: `grid-template-columns: 248px 1fr; min-height: 100vh; background: var(--bg)`.
|
||||
- Inner `<main>` becomes `mt-page` with the mockup's padding (`32px` desktop, responsive down).
|
||||
|
||||
### 3.5 Page layout pattern
|
||||
|
||||
Each route renders:
|
||||
|
||||
```
|
||||
<PageHeader title="..." subtitle="..." crumbs={[...]} actions={...} />
|
||||
<PageContent>...cards / grid...</PageContent>
|
||||
```
|
||||
|
||||
`PageHeader` is a server component that renders the topbar's middle area (title block + breadcrumbs). The sticky outer `TopBar` reads `children` via React's `slots` pattern: simplest is to make `TopBar` itself accept `title/subtitle/crumbs` and have pages set them via a thin `PageHeaderContext` (client provider) or via Next's `template.tsx` + parallel routes. Choose the **Context approach** for minimal churn.
|
||||
|
||||
---
|
||||
|
||||
## 4. Page-by-Page Mapping
|
||||
|
||||
For each route, port the layout from the mockup's JSX while replacing mock data with the existing service hooks.
|
||||
|
||||
| Route | Current file | Mockup source | Notes |
|
||||
| ---------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `/dashboard` | [dashboard/page.tsx](<packages/web/src/app/(dashboard)/dashboard/page.tsx>) | [Dashboard.jsx](docs/design/src/Dashboard.jsx) | Hero + Ring, Today's schedule, Running low, Spending, Supply, Pending orders, Activity. Many cards depend on data we don't yet expose; render skeletons / "Coming soon" states for missing endpoints. |
|
||||
| `/medicines/cabinet` | `medicines/cabinet/` | [Cabinet.jsx](docs/design/src/Cabinet.jsx) | Grid of medicine cards with swatch, supply bar, status pill. |
|
||||
| `/medicines/regimens` | `medicines/regimens/` | `RegimensPage` in [OtherPages.jsx](docs/design/src/OtherPages.jsx) | Per-regimen rows with schedule chips. |
|
||||
| `/medicines/organizer` | `medicines/organizer/` | [Schedule.jsx](docs/design/src/Schedule.jsx) `OrganizerPage` | 7-day x slots grid. |
|
||||
| `/medicines/library` | `medicines/library/` | `LibraryPage` | Filterable medicine table. |
|
||||
| `/medicines/activity` | `medicines/activity/` | `ActivityPage` | Activity feed + spend summary. |
|
||||
| `/refills` (new) | — | `RefillsPage` | Add route under `(dashboard)/refills/`. |
|
||||
| `/purchases` | `purchases/` | `PurchasesPage` | List with status pills. |
|
||||
| `/medicine-prices` | `medicine-prices/` | `PricesPage` | Per-store price comparison. |
|
||||
| `/stores` | `stores/` | `StoresPage` | Store cards. |
|
||||
| `/settings` | `settings/` | `SettingsPage` | Household + account sections. |
|
||||
|
||||
Also add a **Schedule & Log** page (`/schedule` or `/medicines/schedule`) per mockup — currently missing.
|
||||
|
||||
Update [medicines/page.tsx](<packages/web/src/app/(dashboard)/medicines/page.tsx>) (and [dashboard/page.tsx](<packages/web/src/app/(dashboard)/dashboard/page.tsx>) if kept) to use `Card`/`CardHeader` instead of ad-hoc Tailwind classes.
|
||||
|
||||
---
|
||||
|
||||
## 5. Phased Rollout
|
||||
|
||||
Treat this as 5 PR-sized increments, each independently shippable.
|
||||
|
||||
### Phase A — Foundations (no visible page changes yet)
|
||||
|
||||
1. Port tokens.css into `globals.css` (CSS vars + Tailwind v4 `@theme` mappings + dark overrides).
|
||||
2. Wire fonts via `next/font/google` in `app/layout.tsx`; bind to `--font-*` vars.
|
||||
3. Add `ThemeProvider` + FOUC-safe inline script.
|
||||
4. Add `Icon` component and the icon set.
|
||||
5. Add UI primitives: `Card`, `CardHeader`, `Button`, `Pill`, `IconButton`, `Avatar`, `Kbd`, `SearchInput`, `Breadcrumbs`, `Ring`, `SupplyBar`, `SparkBars`.
|
||||
6. Vitest unit tests + RTL smoke tests for each primitive.
|
||||
|
||||
### Phase B — App shell
|
||||
|
||||
1. Rewrite `Sidebar` with grouped nav, icons, active-route detection via `usePathname`, footer.
|
||||
2. Rewrite `TopBar` with title/subtitle/crumbs slots, search, theme toggle, notifications.
|
||||
3. Add `PageHeaderContext` (or a header slot mechanism) so pages can set the topbar content.
|
||||
4. Update `(dashboard)/layout.tsx` grid + `<main>` styling.
|
||||
5. Snapshot/RTL tests for shell.
|
||||
|
||||
### Phase C — Dashboard
|
||||
|
||||
1. Re-implement `/dashboard` with hero + ring + cards using real data where available, skeletons elsewhere.
|
||||
2. Wire "Running low" to existing cabinet endpoint; "Pending orders" to purchases; "Activity" to activity endpoint.
|
||||
3. Tests: render with seeded SWR cache.
|
||||
|
||||
### Phase D — Medicine pages
|
||||
|
||||
1. Port Cabinet, Library, Regimens, Organizer, Activity, Schedule & Log one at a time.
|
||||
2. Each PR: lift JSX from the mockup, replace mock data with `services/*` SWR hooks, add tests.
|
||||
|
||||
### Phase E — Commerce + settings
|
||||
|
||||
1. Port Refills (new route), Purchases, Prices, Stores, Settings.
|
||||
2. Add accent picker UI in Settings.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tailwind v4 Token Mapping (concrete snippet)
|
||||
|
||||
In [packages/web/src/styles/globals.css](packages/web/src/styles/globals.css):
|
||||
|
||||
```css
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Inter Tight', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display: 'Fraunces', ui-serif, Georgia, serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
|
||||
|
||||
--color-bg: var(--bg);
|
||||
--color-bg-elev: var(--bg-elev);
|
||||
--color-bg-inset: var(--bg-inset);
|
||||
--color-border: var(--border);
|
||||
--color-border-strong: var(--border-strong);
|
||||
--color-ink: var(--ink);
|
||||
--color-ink-strong: var(--ink-strong);
|
||||
--color-ink-muted: var(--ink-muted);
|
||||
--color-ink-faint: var(--ink-faint);
|
||||
--color-brand: var(--brand);
|
||||
--color-brand-deep: var(--brand-deep);
|
||||
--color-brand-soft: var(--brand-soft);
|
||||
--color-danger: var(--danger);
|
||||
--color-warn: var(--warn);
|
||||
--color-ok: var(--ok);
|
||||
--color-info: var(--info);
|
||||
|
||||
--radius-xs: var(--r-xs);
|
||||
--radius-sm: var(--r-sm);
|
||||
--radius-md: var(--r-md);
|
||||
--radius-lg: var(--r-lg);
|
||||
--radius-xl: var(--r-xl);
|
||||
}
|
||||
|
||||
:root {
|
||||
/* paste the :root block from docs/design/styles/tokens.css */
|
||||
}
|
||||
[data-theme='dark'] {
|
||||
/* paste the dark block */
|
||||
}
|
||||
```
|
||||
|
||||
This lets us write `bg-bg-elev text-ink-strong border-border rounded-lg shadow-md` while still being able to use the raw `var(--brand)` inside inline SVGs (matching the mockup's pattern).
|
||||
|
||||
---
|
||||
|
||||
## 7. Risks & Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| FOUC on theme switch / SSR mismatch | Inline script in `<head>` reads `localStorage` and sets `data-theme` before React hydrates. Mark theme-dependent UI as client-only when needed. |
|
||||
| Mockup uses inline `style={{...var(--viz-X)...}}` heavily | Keep that pattern in components; tokens are CSS vars so this works fine. |
|
||||
| Real data shape differs from mock data | For each card, define a small typed view-model and a transformer from API DTO; render skeletons when data is unavailable. |
|
||||
| Tailwind v4 `@theme` + custom CSS vars interplay | Verified above: declare CSS vars in `:root`/`[data-theme=dark]`, then map them to `--color-*` inside `@theme` so utilities pick them up. |
|
||||
| Test snapshots churn | Co-locate component tests, prefer RTL queries by role/text over snapshots; use snapshots only for the icon set. |
|
||||
| Scope creep | Strictly follow the phased rollout; each phase is independently mergeable. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Out-of-Scope Follow-ups
|
||||
|
||||
- `⌘K` command palette (search input is visual-only initially).
|
||||
- Real notification feed.
|
||||
- Per-user accent persistence in DB (initially `localStorage` only).
|
||||
- Mobile drawer for the sidebar (mockup is desktop-first; add a `< 900px` breakpoint that collapses to a top hamburger).
|
||||
- Animations beyond the existing CSS transitions.
|
||||
|
||||
---
|
||||
|
||||
## 9. Definition of Done (per phase)
|
||||
|
||||
- All new components have unit tests; coverage for `packages/web` does not regress.
|
||||
- `npm run lint` and `npm run typecheck` pass at the workspace root.
|
||||
- Visual parity verified against the relevant screenshot in `docs/design/screenshots/`.
|
||||
- No usage of `any`; all view-models typed; all client-only files marked `'use client'`.
|
||||
- No regressions in existing E2E tests.
|
||||
|
|
@ -16,6 +16,12 @@
|
|||
6. LLM provider interface (`ILlmProvider`) with no-op implementation
|
||||
7. "Smart Add" endpoint placeholder
|
||||
|
||||
## Design Principles
|
||||
|
||||
- **Metric-only storage**: Products store nutrition relative to a metric serving (`g`, `ml`) or a discrete unit (`piece`, `slice`). Imperial/volume cooking units (oz, cup, tbsp, tsp) are a recipe-input concern and are normalized to metric in Phase 6 before persistence. This keeps nutrition math density-free at the product level.
|
||||
- **Per-household catalog**: Every product is owned by exactly one household. There is no cross-household sharing in this phase; a global/public catalog can be added later via an explicit seed dataset.
|
||||
- **Soft delete**: Deletes set `deletedAt` rather than removing rows, so historical recipes/pantry/grocery references stay resolvable.
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
|
@ -29,18 +35,19 @@ export interface Product {
|
|||
householdId: string;
|
||||
name: string;
|
||||
brand?: string;
|
||||
barcode?: string;
|
||||
barcode?: string; // EAN-13 / UPC-A, digits only
|
||||
category: ProductCategory;
|
||||
servingSize: number;
|
||||
servingUnit: ServingUnit;
|
||||
nutrition: NutritionInfo;
|
||||
servingSize: number; // quantity of one serving in `servingUnit`
|
||||
servingUnit: ServingUnit; // metric or discrete only
|
||||
densityGPerMl?: number; // optional, used by Phase 6 to convert volume cooking units
|
||||
nutrition: NutritionInfo; // values are PER serving (size = servingSize servingUnit)
|
||||
tags: string[];
|
||||
imageUrl?: string;
|
||||
isPublic: boolean; // Visible to all households (for shared catalog)
|
||||
source: ProductSource; // 'manual' | 'barcode_lookup' | 'llm' | 'import'
|
||||
source: ProductSource;
|
||||
createdBy: string; // userId
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletedAt?: Date; // soft delete
|
||||
}
|
||||
|
||||
export interface NutritionInfo {
|
||||
|
|
@ -81,14 +88,14 @@ export enum ProductCategory {
|
|||
export enum ServingUnit {
|
||||
GRAMS = 'g',
|
||||
MILLILITERS = 'ml',
|
||||
OUNCES = 'oz',
|
||||
CUPS = 'cup',
|
||||
TABLESPOONS = 'tbsp',
|
||||
TEASPOONS = 'tsp',
|
||||
PIECES = 'piece',
|
||||
SLICES = 'slice',
|
||||
}
|
||||
|
||||
// Note: imperial/volume cooking units (oz, cup, tbsp, tsp) are intentionally
|
||||
// excluded. Recipes may receive them as input in Phase 6 and convert to metric
|
||||
// before persisting. See `phase-6-recipes.md` for the conversion rules.
|
||||
|
||||
export enum ProductSource {
|
||||
MANUAL = 'manual',
|
||||
BARCODE_LOOKUP = 'barcode_lookup',
|
||||
|
|
@ -104,11 +111,13 @@ export enum ProductSource {
|
|||
{ name: 'text', brand: 'text', tags: 'text' }
|
||||
|
||||
// Compound indexes
|
||||
{ householdId: 1, category: 1 }
|
||||
{ householdId: 1, barcode: 1 } // unique within household
|
||||
{ householdId: 1, deletedAt: 1, category: 1 }
|
||||
{ householdId: 1, barcode: 1 } // partial index where barcode exists & deletedAt is null; unique within household
|
||||
{ householdId: 1, name: 1, brand: 1 } // near-unique for dedup
|
||||
```
|
||||
|
||||
All list/search queries filter `deletedAt: { $exists: false }` (or `null`).
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
|
@ -126,15 +135,22 @@ export enum ProductSource {
|
|||
| POST | `/products/import` | Bulk import from CSV/JSON | admin |
|
||||
| POST | `/products/smart-add` | LLM-powered add from text/image | member |
|
||||
|
||||
Notes:
|
||||
|
||||
- `DELETE /products/:id` is a soft delete; the product is hidden from listings but remains resolvable by id for historical references in recipes, pantry, and grocery.
|
||||
- `POST /products` rejects payloads whose `barcode` collides with an existing non-deleted product in the household (409 `ConflictError`).
|
||||
|
||||
### Query Parameters for GET `/products`
|
||||
|
||||
```
|
||||
?q=chicken # Full-text search
|
||||
?q=chicken # Full-text search (name, brand, tags)
|
||||
&category=meat # Filter by category
|
||||
&tags=organic,fresh # Filter by tags (AND)
|
||||
&cursor=abc123 # Cursor-based pagination
|
||||
&barcode=0123456789012 # Exact barcode match
|
||||
&includeDeleted=false # Default false; admins can pass true
|
||||
&cursor=abc123 # Cursor-based pagination (opaque)
|
||||
&limit=20 # Page size (max 100)
|
||||
&sort=name|-updatedAt # Sort field, prefix - for desc
|
||||
&sort=name|-updatedAt # Sort field, prefix - for desc; default -updatedAt
|
||||
```
|
||||
|
||||
### Response Shape
|
||||
|
|
@ -157,30 +173,40 @@ interface PaginatedResponse<T> {
|
|||
### 5.1 — Shared Types & Validation
|
||||
|
||||
- Add all types above to `packages/shared/src/types/product.ts`
|
||||
- Add enums to `packages/shared/src/enums/`
|
||||
- Create Zod schemas:
|
||||
- `CreateProductSchema` — validates create payload
|
||||
- `UpdateProductSchema` — partial, validates update payload
|
||||
- `ProductQuerySchema` — validates query params
|
||||
- Add enums to `packages/shared/src/enums/product.enums.ts` (`ProductCategory`, `ServingUnit`, `ProductSource`)
|
||||
- Create Zod schemas in `packages/shared/src/validation/product.validation.ts`:
|
||||
- `CreateProductSchema` — validates create payload; `servingSize > 0`; `nutrition` macros `>= 0`; `barcode` matches `/^\d{8,14}$/`
|
||||
- `UpdateProductSchema` — `CreateProductSchema.partial()`
|
||||
- `ProductQuerySchema` — validates query params; `limit` clamped to `[1, 100]`, default 20
|
||||
- `ImportProductsSchema` — array of `CreateProductSchema` (for JSON import)
|
||||
- All schemas imported from `'zod/v4'`; enums use `z.enum(Object.values(...))`.
|
||||
|
||||
### 5.2 — Mongoose Schema & Repository
|
||||
|
||||
- `packages/api/src/modules/products/schemas/product.schema.ts`
|
||||
- `packages/api/src/modules/products/schemas/product.schema.ts` — Mongoose schema with `timestamps: true`, `deletedAt` index, partial unique index on `(householdId, barcode)`.
|
||||
- `ProductRepository` with:
|
||||
- `findByHousehold(householdId, query)` — supports text search, filters, cursor pagination
|
||||
- `findByBarcode(householdId, barcode)`
|
||||
- `findByHousehold(householdId, query)` — text search, filters, cursor pagination, excludes soft-deleted
|
||||
- `findById(id, householdId)` — also returns soft-deleted (for historical resolution)
|
||||
- `findByBarcode(householdId, barcode)` — excludes soft-deleted
|
||||
- `findByIds(householdId, ids[])` — batch fetch for recipe/pantry resolution
|
||||
- `create(data)`
|
||||
- `update(id, householdId, data)`
|
||||
- `softDelete(id, householdId)`
|
||||
- `bulkCreate(items[])`
|
||||
- `softDelete(id, householdId)` — sets `deletedAt`
|
||||
- `bulkCreate(householdId, items[])` — uses `insertMany` with `ordered: false`
|
||||
- All read queries use `.lean().exec()`.
|
||||
|
||||
### 5.3 — Barcode Lookup Service
|
||||
|
||||
- `BarcodeService`:
|
||||
- First check local DB for matching barcode
|
||||
- If not found, query Open Food Facts API (`https://world.openfoodfacts.org/api/v2/product/{barcode}`)
|
||||
- Map OFF response to `Product` shape
|
||||
- Cache results in local DB with `source: 'barcode_lookup'`
|
||||
- First check local DB for matching barcode (per household)
|
||||
- If not found, call Open Food Facts API: `https://world.openfoodfacts.org/api/v2/product/{barcode}`
|
||||
- Map OFF response to `Product` shape:
|
||||
- `product_name` → `name`; `brands` (first) → `brand`; `categories_tags` → derived `ProductCategory`
|
||||
- Nutrition normalized to per-serving (`g` or `ml`) using `serving_size` / `serving_quantity` from OFF; fall back to per-100g if absent
|
||||
- Drop fields with no usable value (do not fabricate zeroes)
|
||||
- Cache result in local DB with `source: 'barcode_lookup'`, owned by the requesting household
|
||||
- Failures (network, 404, malformed): return `{ found: false }`; do not throw
|
||||
- Outbound HTTP via `undici` with a 5s timeout and a configurable User-Agent (`MeshiTrack/<version> (+self-hosted)`)
|
||||
|
||||
### 5.4 — LLM Provider Interface
|
||||
|
||||
|
|
@ -214,38 +240,46 @@ export const LLM_PROVIDER = Symbol('LLM_PROVIDER');
|
|||
|
||||
### 5.6 — Import Endpoint
|
||||
|
||||
- `POST /products/import` accepts multipart CSV or JSON file
|
||||
- Validate each row against `CreateProductSchema`
|
||||
- Return summary: `{ imported: N, skipped: M, errors: [...] }`
|
||||
- CSV column mapping: `name, brand, barcode, category, servingSize, servingUnit, calories, protein, carbs, fat, ...`
|
||||
- `POST /products/import` accepts multipart CSV or JSON file (max 5 MB, 5000 rows)
|
||||
- Validate each row against `CreateProductSchema`; reject rows with imperial `servingUnit` values with a clear error message
|
||||
- De-dup by `(householdId, barcode)` and `(householdId, name, brand)`; existing matches are reported as `skipped`
|
||||
- Return summary: `{ imported: N, skipped: M, errors: [{ row, message }] }`
|
||||
- CSV column mapping: `name, brand, barcode, category, servingSize, servingUnit, densityGPerMl, calories, protein, carbs, fat, fiber, sugar, sodium, saturatedFat, cholesterol, tags`
|
||||
- `tags` is a `;`-separated list
|
||||
- `servingUnit` ∈ `{g, ml, piece, slice}`
|
||||
|
||||
### 5.7 — Web UI: Product Library
|
||||
|
||||
- `/products` page:
|
||||
- Search bar with debounced full-text search
|
||||
- `/products` page (Server Component for initial fetch; client island for filters):
|
||||
- Search bar with debounced full-text search (300ms)
|
||||
- Category filter dropdown
|
||||
- Tag filter chips
|
||||
- Product grid/list view (toggle)
|
||||
- Each product card shows: name, brand, category icon, calories/serving
|
||||
- Product grid/list view (toggle, persisted in `localStorage`)
|
||||
- Each product card shows: name, brand, category icon, calories per serving, serving (`100 g`, `250 ml`, `1 piece`)
|
||||
- Add/Edit product modal:
|
||||
- Form fields for all product properties
|
||||
- Form fields for all product properties; `servingUnit` select limited to `g | ml | piece | slice`
|
||||
- Nutrition input section with per-serving values
|
||||
- Barcode field with "Lookup" button
|
||||
- Optional `densityGPerMl` field (only relevant for liquids/pastes)
|
||||
- Barcode field with "Lookup" button (calls `/products/barcode/:code`)
|
||||
- "Smart Add" tab (text input or image upload)
|
||||
- Import dialog: file upload with preview and error display
|
||||
- Import dialog: file upload with preview, row count, and error display
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Can create, read, update, delete products via API
|
||||
- [ ] Full-text search returns relevant results
|
||||
- [ ] Barcode lookup fetches from Open Food Facts when not in local DB
|
||||
- [ ] Bulk import processes a CSV with 100+ products
|
||||
- [ ] Can create, read, update, soft-delete products via API
|
||||
- [ ] Soft-deleted products remain resolvable by id but excluded from listings
|
||||
- [ ] `ServingUnit` is restricted to `g | ml | piece | slice`; imperial values are rejected at validation
|
||||
- [ ] Full-text search returns relevant results across name, brand, tags
|
||||
- [ ] Barcode lookup fetches from Open Food Facts when not in local DB and caches the result
|
||||
- [ ] Barcode collisions within a household return 409
|
||||
- [ ] Bulk import processes a CSV with 100+ products and reports per-row errors
|
||||
- [ ] Web UI allows searching, filtering, adding, and editing products
|
||||
- [ ] `ILlmProvider` interface is defined and injectable
|
||||
- [ ] Smart Add endpoint returns graceful "not available" with NoOp provider
|
||||
- [ ] Smart Add endpoint returns graceful `{ available: false }` with the NoOp provider
|
||||
- [ ] All product queries are scoped to `householdId`
|
||||
- [ ] Unit + integration tests meet coverage targets (100% lines/functions/statements, 90% branches)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -50,13 +50,21 @@ export interface Recipe {
|
|||
export interface RecipeIngredient {
|
||||
productId: string; // Reference to Product
|
||||
productName: string; // Denormalized for display
|
||||
quantity: number;
|
||||
unit: ServingUnit;
|
||||
quantity: number; // stored in metric (g | ml) or as a discrete count
|
||||
unit: RecipeUnit; // metric/discrete only after normalization
|
||||
originalQuantity?: number; // optional, preserved from import (e.g. 1)
|
||||
originalUnit?: ImperialUnit | RecipeUnit; // optional, preserved from import (e.g. 'cup')
|
||||
preparation?: string; // e.g., 'diced', 'minced', 'melted'
|
||||
isOptional: boolean;
|
||||
nutritionContribution: NutritionInfo; // Per-ingredient computed nutrition
|
||||
}
|
||||
|
||||
// Storage units for recipe ingredients — same set as `ServingUnit` in Phase 5.
|
||||
export type RecipeUnit = 'g' | 'ml' | 'piece' | 'slice';
|
||||
|
||||
// Accepted at input/import only; converted to `RecipeUnit` before persistence.
|
||||
export type ImperialUnit = 'oz' | 'lb' | 'cup' | 'tbsp' | 'tsp' | 'fl_oz';
|
||||
|
||||
export interface RecipeStep {
|
||||
order: number;
|
||||
instruction: string;
|
||||
|
|
@ -143,7 +151,7 @@ class NutritionCalculatorService {
|
|||
/**
|
||||
* For each ingredient:
|
||||
* 1. Lookup the product by productId
|
||||
* 2. Convert ingredient quantity/unit to product's servingUnit
|
||||
* 2. Convert ingredient quantity/unit to product's servingUnit (already metric)
|
||||
* 3. Calculate nutrition proportionally: (ingredient_qty / serving_size) * nutrition_per_serving
|
||||
* 4. Sum across all ingredients → totalNutrition
|
||||
* 5. Divide by servings → perServingNutrition
|
||||
|
|
@ -160,17 +168,26 @@ class NutritionCalculatorService {
|
|||
}
|
||||
```
|
||||
|
||||
- Unit conversion helper: handle common conversions (g ↔ oz, ml ↔ cups, etc.)
|
||||
- Not all conversions are possible (density-dependent) — log warning, use best approximation
|
||||
- This is explicitly **informative, not clinical-grade accurate**
|
||||
- Because products are stored in metric (`g | ml | piece | slice`), the calculator only needs to bridge metric ↔ metric and discrete ↔ metric (via `Product.servingSize`).
|
||||
- Imperial input handling lives in `UnitConversionService` (see 6.2a) and runs **before** the calculator at create/update/import time.
|
||||
|
||||
### 6.2a — Unit Conversion Service
|
||||
|
||||
- `UnitConversionService.toMetric(quantity, unit, product)` returns `{ quantity, unit: RecipeUnit }`.
|
||||
- Mass conversions (exact): `oz → g` (× 28.3495), `lb → g` (× 453.592).
|
||||
- Volume conversions (exact, US customary): `tsp → ml` (× 4.92892), `tbsp → ml` (× 14.7868), `fl_oz → ml` (× 29.5735), `cup → ml` (× 236.588).
|
||||
- Mass ↔ volume conversions require `product.densityGPerMl`. If absent, the service returns an error tagged `MISSING_DENSITY` and the route returns 422 with the offending ingredient so the user can either supply a density on the product or restate the quantity in the product's native unit.
|
||||
- Discrete units (`piece`, `slice`) cannot be converted from imperial — reject at validation.
|
||||
- This service is **input-side only**: ingredients persisted on a recipe are always already in `RecipeUnit`.
|
||||
|
||||
### 6.3 — Recipe CRUD with Auto-Calculation
|
||||
|
||||
- On `POST /recipes` and `PATCH /recipes/:id`:
|
||||
1. Validate ingredients exist in product library
|
||||
2. Call `NutritionCalculatorService.calculateRecipeNutrition()`
|
||||
3. Call `NutritionCalculatorService.generateWarnings()`
|
||||
4. Store computed `totalNutrition`, `perServingNutrition`, `warnings` on document
|
||||
1. Validate ingredients exist in product library (including soft-deleted)
|
||||
2. Run each ingredient through `UnitConversionService.toMetric()` so persisted `unit` is always `RecipeUnit`; preserve the user's original input as `originalQuantity`/`originalUnit` for display
|
||||
3. Call `NutritionCalculatorService.calculateRecipeNutrition()`
|
||||
4. Call `NutritionCalculatorService.generateWarnings()`
|
||||
5. Store computed `totalNutrition`, `perServingNutrition`, `warnings` on document
|
||||
- On product nutrition update (Phase 5 edit), trigger background recalculation:
|
||||
- Find all recipes where `ingredients[].productId == updatedProductId`
|
||||
- Recalculate each recipe's nutrition
|
||||
|
|
@ -186,9 +203,9 @@ class NutritionCalculatorService {
|
|||
- `POST /recipes/import-text`:
|
||||
- Accepts `{ text: string }` (pasted recipe)
|
||||
- Calls `ILlmProvider.parseRecipe(text)`
|
||||
- LLM returns structured: `{ name, servings, ingredients[]: { name, quantity, unit }, steps[] }`
|
||||
- Service attempts to match ingredient names to existing products (fuzzy match by name)
|
||||
- Returns structured recipe for user review — unmatched ingredients flagged for manual product creation
|
||||
- LLM returns structured: `{ name, servings, ingredients[]: { name, quantity, unit }, steps[] }` where `unit` may be imperial
|
||||
- Service runs each ingredient through `UnitConversionService.toMetric()` and matches names to existing products (fuzzy match by name)
|
||||
- Returns the structured recipe for user review — unmatched ingredients and `MISSING_DENSITY` failures are flagged for manual resolution; nothing is persisted yet
|
||||
- `POST /recipes/import-url`:
|
||||
- Calls `ILlmProvider.parseRecipeFromUrl(url)`
|
||||
- Same flow as text import
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue