Setup initial project

This commit is contained in:
Aerilyn Weber 2026-03-27 14:50:34 +09:00
commit db79af06f7
119 changed files with 20761 additions and 0 deletions

View file

@ -0,0 +1,222 @@
# Phase 0 — Foundation & Infrastructure
**Goal**: Repo scaffolding, Docker environment, and authentication. After this phase, a developer can clone the repo, run `docker compose up`, and see a working authenticated "Hello World" page.
---
## Deliverables
1. Monorepo initialized with Turborepo
2. NestJS API with health endpoint
3. Next.js web app with landing page
4. Shared types package building and importable
5. Docker Compose with all services running
6. Keycloak configured with realm, roles, and test users
7. Auth guard protecting API routes
8. User and Household schemas with basic CRUD
9. Dev seed script
10. Linting and formatting
---
## Tasks
### 0.1 — Monorepo Setup
- Initialize root `package.json` with workspaces: `packages/*`
- Add Turborepo config (`turbo.json`) with pipelines: `build`, `dev`, `lint`, `test`
- Create three packages:
- `packages/shared` — TypeScript library, compiled with `tsc`
- `packages/api` — NestJS app (`@nestjs/cli` scaffold)
- `packages/web` — Next.js app (`create-next-app` with App Router, TypeScript, Tailwind CSS)
- Root `tsconfig.base.json` with strict settings, extended by each package
- Configure package references so `api` and `web` depend on `shared`
### 0.2 — Shared Types (Initial)
Define in `packages/shared/src/`:
```typescript
// enums/roles.ts
export enum HouseholdRole {
OWNER = 'owner',
ADMIN = 'admin',
MEMBER = 'member',
}
// types/user.ts
export interface User {
id: string;
keycloakId: string;
displayName: string;
email: string;
householdIds: string[];
defaultHouseholdId: string;
createdAt: Date;
updatedAt: Date;
}
// types/household.ts
export interface Household {
id: string;
name: string;
ownerUserId: string;
members: HouseholdMember[];
inviteCode: string;
settings: HouseholdSettings;
createdAt: Date;
updatedAt: Date;
}
export interface HouseholdMember {
userId: string;
role: HouseholdRole;
joinedAt: Date;
}
export interface HouseholdSettings {
timezone: string;
currency: string;
language: string;
}
```
- Add Zod validation schemas for create/update DTOs
- Export everything from `index.ts`
### 0.3 — Docker Compose
Create `docker/docker-compose.yml`:
```yaml
services:
mongodb:
image: mongo:7
ports: ['27017:27017']
volumes: [mongo-data:/data/db]
environment:
MONGO_INITDB_ROOT_USERNAME: meshitrack
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD}
keycloak:
image: quay.io/keycloak/keycloak:24.0
ports: ['8080:8080']
environment:
KC_DB: dev-mem # Dev mode, in-memory DB
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD}
command: start-dev --import-realm
volumes:
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json
api:
build:
context: ..
dockerfile: docker/Dockerfile.api
ports: ['3001:3001']
depends_on: [mongodb, keycloak]
environment:
MONGODB_URI: mongodb://meshitrack:${MONGO_PASSWORD}@mongodb:27017/meshitrack?authSource=admin
KEYCLOAK_URL: http://keycloak:8080
KEYCLOAK_REALM: meshitrack
KEYCLOAK_CLIENT_ID: meshitrack-api
web:
build:
context: ..
dockerfile: docker/Dockerfile.web
ports: ['3000:3000']
depends_on: [api]
environment:
NEXT_PUBLIC_API_URL: http://localhost:3001/api/v1
NEXT_PUBLIC_KEYCLOAK_URL: http://localhost:8080
NEXT_PUBLIC_KEYCLOAK_REALM: meshitrack
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID: meshitrack-web
mongo-express:
image: mongo-express
ports: ['8081:8081']
depends_on: [mongodb]
environment:
ME_CONFIG_MONGODB_ADMINUSERNAME: meshitrack
ME_CONFIG_MONGODB_ADMINPASSWORD: ${MONGO_PASSWORD}
ME_CONFIG_MONGODB_URL: mongodb://meshitrack:${MONGO_PASSWORD}@mongodb:27017/
profiles: [dev]
volumes:
mongo-data:
```
- Create `.env.example` with all required variables
- Create `Dockerfile.api` and `Dockerfile.web` (multi-stage builds)
### 0.4 — Keycloak Configuration
- Export a Keycloak realm JSON (`docker/keycloak/realm-export.json`) with:
- Realm: `meshitrack`
- Client: `meshitrack-web` (public, PKCE) for frontend
- Client: `meshitrack-api` (bearer-only) for backend validation
- Realm roles: `admin`, `member`
- Custom protocol mapper: `household-mapper` that maps user attribute `householdIds` to JWT claim
- Test users: `testuser1` / `testuser2` with passwords
### 0.5 — NestJS API Bootstrap
- `packages/api/src/main.ts`: bootstrap NestJS with:
- Global prefix `/api/v1`
- CORS configured for `http://localhost:3000`
- Swagger/OpenAPI docs at `/api/docs`
- Validation pipe (class-validator + class-transformer)
- Modules:
- `AuthModule`: Keycloak strategy (`passport-openidconnect` or `nest-keycloak-connect`), `@AuthGuard` decorator, extract user + householdId from JWT
- `UsersModule`: `User` Mongoose schema, sync user on first login (upsert from Keycloak token)
- `HouseholdsModule`: `Household` Mongoose schema, CRUD endpoints
- `POST /households` — create (creator becomes owner)
- `GET /households/:householdId` — get (members only)
- `POST /households/:householdId/invite` — generate invite code
- `POST /households/join` — join via invite code
- `PATCH /households/:householdId` — update settings (admin/owner)
- `HealthModule`: `GET /api/v1/health` — returns `{ status: 'ok', version, uptime }`
- Common:
- `HouseholdPlugin`: Fastify preHandler hook that reads `:householdId` from the URI, validates it against the user's `householdIds[]` JWT claim, and injects it into `request.householdId`
- `CurrentUser` param decorator
- `CurrentHousehold` param decorator
- Global exception filter with consistent error response shape
### 0.6 — Next.js Web Bootstrap
- Configure `next-auth` or `keycloak-js` for OIDC login flow
- Pages:
- `/login` — redirects to Keycloak
- `/` — dashboard (protected, shows "Welcome, {name}" + household selector)
- `/settings` — household management (create, invite, switch)
- Layout: navigation sidebar (placeholder links for future phases), top bar with user avatar + household switcher
- API client service (`packages/web/src/services/api-client.ts`): fetch wrapper that attaches the JWT `Authorization` header; household context is passed via URL (e.g. `/api/v1/households/:householdId/products`)
### 0.7 — Dev Seed Script
- `packages/api/src/scripts/seed.ts`:
- Create 2 test users (matching Keycloak test users)
- Create 1 household with both users as members
- Log credentials and household ID to console
---
## Acceptance Criteria
- [ ] `docker compose up` starts all services without errors
- [ ] Navigating to `http://localhost:3000` redirects to Keycloak login
- [ ] After login, dashboard shows user name and household
- [ ] `GET /api/v1/health` returns 200
- [ ] `GET /api/v1/households/:id` returns 401 without token, 200 with valid token
- [ ] `packages/shared` types are importable from both `api` and `web`
---
## Dependencies
None — this is the foundation phase.
## Estimated Effort
Medium-large. Mostly boilerplate and configuration, but Keycloak setup requires careful attention.

View file

@ -0,0 +1,214 @@
# Phase 1 — Medicine Library
**Goal**: A searchable catalog of medicines with dosage and form information. Medicines are the atomic building blocks for regimens, cabinet inventory, and refill tracking.
**Depends on**: Phase 0 (auth, households, shared types)
---
## Deliverables
1. `Medicine` MongoDB schema and full CRUD API
2. Full-text search with filters
3. Barcode lookup (future: integration with drug database APIs)
4. Bulk import (CSV/JSON)
5. Medicine library web UI (search, add, edit)
---
## Data Model
### Medicine Schema
```typescript
// packages/shared/src/types/medicine.ts
export interface Medicine {
id: string;
householdId: string;
name: string;
genericName?: string;
brand?: string;
barcode?: string;
form: MedicineForm;
strength: number;
strengthUnit: StrengthUnit;
category: MedicineCategory;
activeIngredient?: string;
manufacturer?: string;
notes?: string;
imageUrl?: string;
tags: string[];
source: MedicineSource;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
export enum MedicineForm {
TABLET = 'tablet',
CAPSULE = 'capsule',
LIQUID = 'liquid',
CREAM = 'cream',
INJECTION = 'injection',
INHALER = 'inhaler',
PATCH = 'patch',
DROPS = 'drops',
POWDER = 'powder',
SUPPOSITORY = 'suppository',
OTHER = 'other',
}
export enum StrengthUnit {
MG = 'mg',
MCG = 'mcg',
G = 'g',
ML = 'ml',
IU = 'IU',
PERCENT = '%',
OTHER = 'other',
}
export enum MedicineCategory {
PRESCRIPTION = 'prescription',
OTC = 'otc',
SUPPLEMENT = 'supplement',
VITAMIN = 'vitamin',
HERBAL = 'herbal',
OTHER = 'other',
}
export enum MedicineSource {
MANUAL = 'manual',
BARCODE_LOOKUP = 'barcode_lookup',
IMPORT = 'import',
}
```
### MongoDB Indexes
```javascript
// Text index for search
{ name: 'text', genericName: 'text', brand: 'text', activeIngredient: 'text', tags: 'text' }
// Compound indexes
{ householdId: 1, category: 1 }
{ householdId: 1, barcode: 1 } // unique within household
{ householdId: 1, name: 1, strength: 1, form: 1 } // near-unique for dedup
```
---
## API Endpoints
### MedicinesModule
| Method | Path | Description | Auth |
| ------ | -------------------------- | -------------------------------- | ------ |
| GET | `/medicines` | List/search medicines (paginated)| member |
| GET | `/medicines/:id` | Get single medicine | member |
| POST | `/medicines` | Create medicine | member |
| PATCH | `/medicines/:id` | Update medicine | member |
| DELETE | `/medicines/:id` | Soft-delete medicine | admin |
| GET | `/medicines/barcode/:code` | Lookup by barcode | member |
| POST | `/medicines/import` | Bulk import from CSV/JSON | admin |
### Query Parameters for GET `/medicines`
```
?q=metformin # Full-text search
&category=prescription # Filter by category
&form=tablet # Filter by form
&tags=daily,morning # Filter by tags (AND)
&cursor=abc123 # Cursor-based pagination
&limit=20 # Page size (max 100)
&sort=name|-updatedAt # Sort field, prefix - for desc
```
### Response Shape
```typescript
interface PaginatedResponse<T> {
data: T[];
pagination: {
cursor: string | null; // null = last page
hasMore: boolean;
total: number;
};
}
```
---
## Tasks
### 1.1 — Shared Types & Validation
- Add all types above to `packages/shared/src/types/medicine.ts`
- Add enums to `packages/shared/src/enums/`
- Create Zod schemas:
- `CreateMedicineSchema` — validates create payload
- `UpdateMedicineSchema` — partial, validates update payload
- `MedicineQuerySchema` — validates query params
### 1.2 — Mongoose Schema & Repository
- `packages/api/src/modules/medicines/medicines.repository.ts`
- `MedicinesRepository` with:
- `findByHousehold(householdId, query)` — supports text search, filters, cursor pagination
- `findByBarcode(householdId, barcode)`
- `findById(id, householdId)`
- `create(data)`
- `update(id, householdId, data)`
- `softDelete(id, householdId)`
- `bulkCreate(items[])`
### 1.3 — Service & Routes
- `MedicinesService` with business logic (dedup check on create, validation)
- `MedicinesRoutes` with Fastify route plugin registering all endpoints
- Register Awilix dependencies via `fp()` plugin
### 1.4 — Barcode Lookup
- `BarcodeLookupService`:
- First check local DB for matching barcode
- Placeholder for external drug database API integration (manual entry fallback)
- Cache results in local DB with `source: 'barcode_lookup'`
### 1.5 — Import Endpoint
- `POST /medicines/import` accepts multipart CSV or JSON file
- Validate each row against `CreateMedicineSchema`
- Return summary: `{ imported: N, skipped: M, errors: [...] }`
- CSV column mapping: `name, genericName, brand, barcode, form, strength, strengthUnit, category, activeIngredient, manufacturer`
### 1.6 — Web UI: Medicine Library
- `/medicines` page:
- Search bar with debounced full-text search
- Category and form filter dropdowns
- Tag filter chips
- Medicine grid/list view (toggle)
- Each medicine card shows: name, strength + unit, form, brand, category badge
- Add/Edit medicine modal:
- Form fields for all medicine properties
- Barcode field with "Lookup" button
- Import dialog: file upload with preview and error display
---
## Acceptance Criteria
- [ ] Can create, read, update, delete medicines via API
- [ ] Full-text search returns relevant results
- [ ] Barcode lookup checks local DB first
- [ ] Bulk import processes a CSV with 100+ medicines
- [ ] Web UI allows searching, filtering, adding, and editing medicines
- [ ] All medicine queries are scoped to `householdId`
- [ ] Dedup check prevents creating duplicate medicines (same name + strength + form)
---
## Estimated Effort
Medium. Straightforward CRUD with search, following the same patterns established in Phase 0.

315
docs/phases/phase-10-llm.md Normal file
View file

@ -0,0 +1,315 @@
# Phase 10 — LLM Integration & Smart Features
**Goal**: Wire up the `ILlmProvider` interface (defined in Phase 5) to real LLM backends. Enable all the smart features that have had placeholder endpoints throughout Phases 5-9. Add new natural language interaction capabilities for both medicine and food domains.
**Depends on**: All previous phases (feature endpoints already exist)
---
## Deliverables
1. Concrete `ILlmProvider` implementations (OpenAI, Anthropic, Ollama)
2. Config-driven provider selection
3. LLM request/response logging and cost tracking
4. Rate limiting and budget controls
5. Smart features fully wired:
- Product recognition (photo → nutrition)
- Receipt parsing (photo → store + items + prices)
- Recipe import (text/URL → structured recipe)
- Natural language pantry entry
- LLM-powered meal plan suggestions
6. Prompt templates and versioning
---
## Architecture
### Provider Selection
```
Environment variable: LLM_PROVIDER_TYPE=openai|anthropic|ollama|noop
LlmModule registers the provider dynamically:
@Module({})
export class LlmModule {
static forRoot(): DynamicModule {
return {
providers: [{
provide: LLM_PROVIDER,
useFactory: (config: ConfigService) => {
switch (config.get('LLM_PROVIDER_TYPE')) {
case 'openai': return new OpenAiProvider(config);
case 'anthropic': return new AnthropicProvider(config);
case 'ollama': return new OllamaProvider(config);
default: return new NoOpLlmProvider();
}
},
inject: [ConfigService],
}],
exports: [LLM_PROVIDER],
};
}
}
```
### Provider Implementations
Each provider implements `ILlmProvider` and handles:
- API authentication (keys from env)
- Model selection (configurable per provider)
- Request/response mapping to/from vendor format
- Error handling and retries (exponential backoff)
- Timeout management
```typescript
// packages/api/src/modules/llm/providers/
├── noop.provider.ts # Returns null for everything (already exists from Phase 5)
├── openai.provider.ts # GPT-4o / GPT-4o-mini
├── anthropic.provider.ts # Claude 3.5 Sonnet / Haiku
└── ollama.provider.ts # Local models (Llama 3, Mistral, etc.)
```
### Environment Configuration
```env
# Provider selection
LLM_PROVIDER_TYPE=openai
# OpenAI
OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-4o
OPENAI_VISION_MODEL=gpt-4o # For image inputs
# Anthropic
ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_MODEL=claude-sonnet-4-20250514
# Ollama
OLLAMA_BASE_URL=http://ollama:11434
OLLAMA_MODEL=llama3
OLLAMA_VISION_MODEL=llava
# Budget
LLM_MONTHLY_BUDGET_USD=20.00
LLM_RATE_LIMIT_RPM=30 # Requests per minute per household
```
---
## Tasks
### 10.1 — OpenAI Provider
```typescript
class OpenAiProvider implements ILlmProvider {
// Uses OpenAI Node.js SDK
// Text endpoints: chat completions with JSON mode
// Vision endpoints: chat completions with image_url or base64 content
// Structured output: use function calling or response_format: json_schema
}
```
- `extractNutrition`: Send product text/image → prompt asks for structured nutrition JSON
- `parseRecipe`: Send recipe text → prompt extracts name, servings, ingredients[], steps[]
- `parseRecipeFromUrl`: Fetch URL content first, then parse as text
- `parseReceipt`: Send receipt image → prompt extracts store, date, line items with prices
- `suggestMealPlan`: Send pantry summary + targets + preferences → get 7-day plan
- `parseNaturalLanguage`: Send user text → extract intent + entities (add product, log purchase, etc.)
### 10.2 — Anthropic Provider
```typescript
class AnthropicProvider implements ILlmProvider {
// Uses Anthropic SDK
// Similar structure to OpenAI but with Claude-specific API format
// Vision: send image as base64 in messages
// Structured output: use tool_use for JSON extraction
}
```
### 10.3 — Ollama Provider
```typescript
class OllamaProvider implements ILlmProvider {
// Uses Ollama REST API (http://host:11434/api/generate or /api/chat)
// Text: standard chat endpoint
// Vision: requires multimodal model (llava, bakllava)
// Note: local models may be less accurate — adjust prompts for simpler output
// No cost tracking needed (self-hosted)
}
```
### 10.4 — Prompt Templates
Create versioned prompt templates in `packages/api/src/modules/llm/prompts/`:
```typescript
// prompts/extract-nutrition.ts
export const EXTRACT_NUTRITION_PROMPT = {
version: '1.0',
system: `You are a nutrition data extraction assistant. Given a food product description or image, extract nutritional information. Return ONLY valid JSON matching the schema below. If you cannot determine a value, use null. Be conservative with estimates.`,
schema: {
name: 'string',
brand: 'string | null',
servingSize: 'number',
servingUnit: 'g | ml | oz | piece',
nutrition: {
calories: 'number',
protein: 'number (grams)',
carbs: 'number (grams)',
fat: 'number (grams)',
fiber: 'number | null',
sugar: 'number | null',
sodium: 'number | null (mg)',
},
},
};
// prompts/parse-recipe.ts
// prompts/parse-receipt.ts
// prompts/suggest-meal-plan.ts
// prompts/parse-natural-language.ts
```
- Each prompt has a `version` for tracking which prompt produced which results
- Prompts are provider-agnostic (providers may wrap them differently)
### 10.5 — LLM Logging & Cost Tracking
```typescript
// Schema
export interface LlmLog {
id: string;
householdId: string;
userId: string;
provider: string; // 'openai' | 'anthropic' | 'ollama'
model: string;
feature: string; // 'extract_nutrition' | 'parse_recipe' | etc.
promptVersion: string;
inputTokens: number;
outputTokens: number;
totalTokens: number;
costUsd: number; // Computed from token count + model pricing
latencyMs: number;
success: boolean;
errorMessage?: string;
createdAt: Date;
}
```
- All provider calls are wrapped in a logging decorator/interceptor
- Cost computed from known per-model pricing (configurable)
- Monthly cost aggregation endpoint for household admins
### 10.6 — Rate Limiting & Budget Controls
```typescript
class LlmBudgetGuard {
/**
* Before each LLM call:
* 1. Check per-household rate limit (requests per minute)
* 2. Check monthly budget: sum costUsd for current month vs LLM_MONTHLY_BUDGET_USD
* 3. If exceeded, throw BudgetExceededException (HTTP 429)
*/
async canProceed(householdId: string): Promise<boolean>;
}
```
- Rate limiting: Redis-backed or in-memory (household-level RPM)
- Budget: MongoDB aggregation on `LlmLog` collection
### 10.7 — Wire Up All Feature Endpoints
Each of these endpoints already exists as a placeholder from earlier phases. Now they get real LLM calls:
| Feature | Endpoint (existing) | Phase | LLM Method |
| --------------------- | ----------------------------------- | ----- | ---------------------- |
| Product recognition | `POST /products/smart-add` | 5 | `extractNutrition()` |
| Recipe import (text) | `POST /recipes/import-text` | 6 | `parseRecipe()` |
| Recipe import (URL) | `POST /recipes/import-url` | 6 | `parseRecipeFromUrl()` |
| Receipt parsing | `POST /prices/parse-receipt` | 9 | `parseReceipt()` |
| Meal plan suggestions | `POST /meal-plans/suggest-with-llm` | 8 | `suggestMealPlan()` |
### 10.8 — Natural Language Input (New Feature)
New universal endpoint:
```
POST /api/v1/nlp/parse
Body: { text: string }
Response: { intent: string, action: StructuredAction, confidence: number }
```
Supported intents:
- `add_pantry_item`: "I bought 2 lbs of chicken at Costco for $12" → create pantry item + price record
- `add_product`: "Add whole milk, 240ml serving, 150 cal, 8g protein, 12g carbs, 8g fat" → create product
- `check_expiry`: "What's expiring this week?" → redirect to pantry query
- `find_recipe`: "What can I make with chicken and rice?" → trigger suggestion engine
- `add_to_list`: "Add eggs and butter to my shopping list" → add items to active list
Each recognized intent maps to an existing API operation, executed automatically or returned as a confirmation prompt.
### 10.9 — Web UI: LLM Settings & Features
- `/settings/llm` page (admin only):
- Provider selection display
- Monthly cost usage bar
- Rate limit configuration
- LLM log viewer (recent calls, success/failure, latency, cost)
- Enhance existing UI with LLM-powered features:
- Product add modal: "Smart Add" tab with camera/text → LLM pre-fill
- Recipe page: "Import from text" and "Import from URL" now functional
- Shopping list: "Scan receipt" button with camera
- Dashboard: natural language input bar ("What should I cook tonight?")
### 10.10 — Docker: Ollama Service (Optional)
If user wants local LLM, add to Docker Compose:
```yaml
ollama:
image: ollama/ollama
ports: ['11434:11434']
volumes: [ollama-models:/root/.ollama]
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
profiles: [llm-local]
```
---
## Acceptance Criteria
- [ ] Can switch LLM provider via environment variable
- [ ] OpenAI provider successfully extracts nutrition from product photo
- [ ] Recipe import from text returns a valid structured recipe
- [ ] Receipt parsing extracts store, items, and prices from receipt image
- [ ] All LLM calls are logged with token counts and cost
- [ ] Rate limiting prevents exceeding configured RPM
- [ ] Monthly budget guard blocks calls when budget is exceeded
- [ ] Natural language input correctly identifies intents and executes actions
- [ ] NoOp provider still works gracefully when no LLM is configured
- [ ] LLM settings page shows usage and cost statistics
---
## Estimated Effort
Large. Multiple provider implementations, prompt engineering, testing across different models, cost tracking infrastructure, and NLP intent parsing are all significant.
---
## Notes
- Prompt engineering is iterative — expect to refine prompts based on real-world testing
- Different providers/models will have varying accuracy — consider model-specific prompt tuning
- Local models (Ollama) will be less accurate but free — document quality trade-offs
- Consider caching LLM results for identical inputs (e.g., same barcode photo → same product)

View file

@ -0,0 +1,252 @@
# Phase 2 — Medicine Cabinet
**Goal**: Track medicine inventory — what you have, how much of each, and when it expires. Provide aggregate views and low-stock/expiry warnings.
**Depends on**: Phase 0, Phase 1 (medicines)
---
## Deliverables
1. `CabinetItem` MongoDB schema and full CRUD API
2. Aggregate quantity view per medicine
3. Expiry date tracking and warnings
4. Low stock alerts (based on configurable thresholds)
5. Medicine cabinet web UI with status indicators
---
## Data Model
### CabinetItem Schema
```typescript
// packages/shared/src/types/cabinet.ts
export interface CabinetItem {
id: string;
householdId: string;
medicineId: string;
medicineName: string; // Denormalized
medicineStrength: number; // Denormalized for display
medicineStrengthUnit: StrengthUnit; // Denormalized
medicineForm: MedicineForm; // Denormalized
quantity: number;
unit: DosageUnit;
expirationDate?: Date;
lotNumber?: string;
purchaseDate?: Date;
purchasePrice?: number;
storeId?: string;
storeName?: string; // Denormalized
status: CabinetItemStatus;
notes?: string;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
export enum DosageUnit {
PILL = 'pill',
CAPSULE = 'capsule',
ML = 'ml',
G = 'g',
PATCH = 'patch',
DOSE = 'dose',
PUFF = 'puff',
DROP = 'drop',
APPLICATION = 'application',
}
export enum CabinetItemStatus {
ACTIVE = 'active',
DEPLETED = 'depleted',
EXPIRED = 'expired',
DISCARDED = 'discarded',
}
```
### CabinetSummary (Computed, not stored)
```typescript
// Aggregate view — total per medicine across all cabinet items
export interface CabinetSummary {
medicineId: string;
medicineName: string;
medicineStrength: number;
medicineStrengthUnit: StrengthUnit;
medicineForm: MedicineForm;
totalQuantity: number;
unit: DosageUnit;
earliestExpiry: Date | null;
itemCount: number; // How many cabinet items (bottles/boxes)
lowStockThreshold?: number; // From household settings
isLowStock: boolean;
}
```
### MongoDB Indexes
```javascript
{ householdId: 1, medicineId: 1, status: 1 }
{ householdId: 1, status: 1 }
{ householdId: 1, expirationDate: 1 } // For expiry warnings
{ householdId: 1, 'quantity': 1 }
```
---
## API Endpoints
### CabinetModule
| Method | Path | Description | Auth |
| ------ | --------------------------- | ----------------------------------------------- | ------ |
| GET | `/cabinet` | List cabinet items (filtered, paginated) | member |
| GET | `/cabinet/summary` | Aggregate quantities per medicine | member |
| GET | `/cabinet/:id` | Get single cabinet item | member |
| POST | `/cabinet` | Add item to cabinet | member |
| PATCH | `/cabinet/:id` | Update item (quantity, notes, etc.) | member |
| POST | `/cabinet/:id/adjust` | Adjust quantity (add/subtract without full edit) | member |
| DELETE | `/cabinet/:id` | Hard delete (admin) | admin |
| GET | `/cabinet/expiring-soon` | Items expiring within N days | member |
| GET | `/cabinet/low-stock` | Medicines below threshold quantity | member |
### Query Parameters for GET `/cabinet`
```
?medicineId=abc123 # Filter by medicine
&status=active # Filter by status
&expiringWithin=30 # Days until expiry
&sort=-expirationDate|name # Sort field
&cursor=abc123
&limit=20
```
### Adjust Quantity Request
```typescript
// POST /cabinet/:id/adjust
interface AdjustQuantityRequest {
delta: number; // Positive to add, negative to subtract
reason?: string; // e.g., "Correcting count", "Dropped a pill"
}
```
---
## Tasks
### 2.1 — Shared Types & Validation
- Add cabinet types to `packages/shared/src/types/cabinet.ts`
- Zod schemas:
- `CreateCabinetItemSchema`
- `UpdateCabinetItemSchema`
- `AdjustQuantitySchema`
- `CabinetQuerySchema`
### 2.2 — Mongoose Schema & Repository
- `packages/api/src/modules/cabinet/cabinet.repository.ts`
- `CabinetRepository` with:
- `findByHousehold(householdId, query)` — filtered, paginated
- `findById(id, householdId)`
- `findByMedicine(householdId, medicineId)` — all items for a medicine
- `getAggregateSummary(householdId)` — MongoDB aggregation pipeline
- `create(data)`
- `update(id, householdId, data)`
- `adjustQuantity(id, householdId, delta)` — atomic `$inc`
- `findExpiringSoon(householdId, withinDays)`
- `delete(id, householdId)`
### 2.3 — Cabinet Service
```typescript
class CabinetService {
/** Add item, denormalizing medicine fields */
addItem(data: CreateCabinetItem): Promise<CabinetItem>;
/** Adjust quantity with floor at 0, auto-set depleted status */
adjustQuantity(id: string, householdId: string, delta: number, reason?: string): Promise<CabinetItem>;
/** Get aggregate summary with low stock flags */
getSummary(householdId: string): Promise<CabinetSummary[]>;
/** Find items expiring within N days */
getExpiringSoon(householdId: string, withinDays: number): Promise<CabinetItem[]>;
/** Find medicines below low stock threshold */
getLowStock(householdId: string): Promise<CabinetSummary[]>;
/**
* Deduct quantity from cabinet items for a medicine (used by Pill Organizer in Phase 3).
* Uses FEFO (First Expiry, First Out) — draws from items with earliest expiry first.
* Returns actual quantity deducted (may be less than requested if insufficient).
*/
deductStock(householdId: string, medicineId: string, quantity: number): Promise<DeductionResult>;
/** Reverse a deduction (used by Pill Organizer undo) */
restoreStock(householdId: string, cabinetItemId: string, quantity: number): Promise<CabinetItem>;
}
interface DeductionResult {
totalDeducted: number;
requested: number;
isShort: boolean;
deductions: {
cabinetItemId: string;
quantityTaken: number;
remainingInItem: number;
}[];
}
```
### 2.4 — Expiry Check Job
- Scheduled job (daily at 6 AM, configurable):
1. Query all active cabinet items with `expirationDate <= today`
2. Update status to `expired`
3. Create in-app notifications for expired items
4. Query items expiring within 7 days, create warning notifications
### 2.5 — Web UI: Medicine Cabinet
- `/cabinet` page:
- **Summary view** (default): aggregated per medicine
- Medicine name, total quantity, earliest expiry, low stock indicator
- Expand to see individual items (bottles/boxes)
- **Detail view**: all individual cabinet items
- Each item shows: medicine name, quantity, expiry date, status badge
- Color-coded expiry: green (>30 days), yellow (7-30 days), red (<7 days), grey (expired)
- Low stock badge on medicines below threshold
- Quick actions: adjust quantity (+/-), discard
- "Add to Cabinet" button -> modal:
- Medicine autocomplete (from library)
- Quantity + unit
- Expiration date (optional)
- Lot number (optional)
- Purchase date, price, store (optional)
- `/cabinet/alerts` or notification panel:
- Expiring soon items
- Low stock warnings
---
## Acceptance Criteria
- [ ] Can add items to cabinet linked to medicines
- [ ] Aggregate summary shows total quantity per medicine
- [ ] Quantity adjustments are atomic and floor at 0
- [ ] Items auto-transition to `depleted` when quantity reaches 0
- [ ] Items auto-transition to `expired` when past expiration date
- [ ] Expiring-soon endpoint returns items within N days
- [ ] Low-stock endpoint compares against configurable thresholds
- [ ] FEFO deduction draws from earliest-expiring items first
- [ ] Web UI shows color-coded expiry indicators
- [ ] All cabinet queries are scoped to `householdId`
---
## Estimated Effort
Medium. CRUD with aggregation pipeline, FEFO logic, and scheduled expiry job. Simpler than food pantry tracking (no freshness estimation).

View file

@ -0,0 +1,354 @@
# Phase 3 — Regimens & Pill Organizer
**Goal**: Define daily medication schedules (regimens) and batch-dispense from the medicine cabinet into a pill organizer. This is the core convenience feature: instead of tracking individual pill consumption daily, users fill their organizer for N days in a single action.
**Depends on**: Phase 0, Phase 1 (medicines), Phase 2 (cabinet)
---
## Deliverables
1. `Regimen` MongoDB schema and CRUD API
2. `OrganizerFill` schema and fill/undo API
3. Pill organizer fill flow with shortage detection
4. Burn rate calculation (days until empty per medicine)
5. Regimen and pill organizer web UI
---
## Data Model
### Regimen Schema
```typescript
// packages/shared/src/types/regimen.ts
export interface Regimen {
id: string;
householdId: string;
userId: string; // Regimens are per-person
name: string; // e.g., "Daily medications", "Morning routine"
isActive: boolean;
medications: RegimenMedication[];
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
export interface RegimenMedication {
medicineId: string;
medicineName: string; // Denormalized
medicineStrength: number; // Denormalized
medicineStrengthUnit: StrengthUnit; // Denormalized
medicineForm: MedicineForm; // Denormalized
dosage: number; // e.g., 2 (pills per dose)
dosageUnit: DosageUnit;
frequency: DosageFrequency;
customFrequencyPerDay?: number; // When frequency is 'custom'
timeOfDay?: TimeOfDay;
instructions?: string; // e.g., "Take with food", "Do not crush"
}
export enum DosageFrequency {
DAILY = 'daily', // 1x per day
TWICE_DAILY = 'twice_daily', // 2x per day
THREE_TIMES_DAILY = 'three_times_daily', // 3x per day
WEEKLY = 'weekly', // 1x per week
EVERY_OTHER_DAY = 'every_other_day',
AS_NEEDED = 'as_needed', // Excluded from organizer fill calculations
CUSTOM = 'custom', // Uses customFrequencyPerDay
}
export enum TimeOfDay {
MORNING = 'morning',
AFTERNOON = 'afternoon',
EVENING = 'evening',
BEDTIME = 'bedtime',
}
```
### OrganizerFill Schema
```typescript
// packages/shared/src/types/organizer-fill.ts
export interface OrganizerFill {
id: string;
householdId: string;
userId: string;
regimenId: string;
regimenName: string; // Denormalized
numberOfDays: number; // Flexible: 1, 6, 7, 14, etc.
fillDate: Date;
items: OrganizerFillItem[];
status: OrganizerFillStatus;
notes?: string;
createdAt: Date;
updatedAt: Date;
}
export interface OrganizerFillItem {
medicineId: string;
medicineName: string;
quantityNeeded: number; // Total pills needed for N days
quantityTaken: number; // Actual pills taken from cabinet
wasShort: boolean; // quantityTaken < quantityNeeded
shortage: number; // quantityNeeded - quantityTaken (0 if not short)
deductions: OrganizerDeduction[];
}
export interface OrganizerDeduction {
cabinetItemId: string;
quantityTaken: number;
}
export enum OrganizerFillStatus {
COMPLETED = 'completed', // All medicines fully dispensed
PARTIAL = 'partial', // Some medicines were short
REVERSED = 'reversed', // Fill was undone
}
```
### BurnRate (Computed, not stored)
```typescript
// Calculated from active regimens + cabinet stock
export interface BurnRate {
medicineId: string;
medicineName: string;
dailyConsumption: number; // Total pills per day across all regimens
totalInCabinet: number;
daysUntilEmpty: number | null; // null if dailyConsumption is 0
earliestExpiry: Date | null;
}
```
### MongoDB Indexes
```javascript
// Regimen
{ householdId: 1, userId: 1, isActive: 1 }
{ householdId: 1, 'medications.medicineId': 1 }
// OrganizerFill
{ householdId: 1, userId: 1, fillDate: -1 }
{ householdId: 1, regimenId: 1, fillDate: -1 }
{ householdId: 1, status: 1 }
```
---
## API Endpoints
### RegimensModule
| Method | Path | Description | Auth |
| ------ | ----------------------- | ------------------------------------ | ------ |
| GET | `/regimens` | List user's regimens | member |
| GET | `/regimens/:id` | Get single regimen | member |
| POST | `/regimens` | Create regimen | member |
| PATCH | `/regimens/:id` | Update regimen | member |
| DELETE | `/regimens/:id` | Delete regimen | member |
| GET | `/regimens/burn-rate` | Burn rate for all active regimens | member |
### OrganizerModule
| Method | Path | Description | Auth |
| ------ | ----------------------------- | -------------------------------------------- | ------ |
| GET | `/organizer/fills` | List fill history (paginated) | member |
| GET | `/organizer/fills/:id` | Get single fill details | member |
| POST | `/organizer/preview` | Preview a fill (shows quantities, shortages) | member |
| POST | `/organizer/fill` | Execute a fill (deduct from cabinet) | member |
| POST | `/organizer/fills/:id/undo` | Reverse a fill (restore cabinet quantities) | member |
### Preview Request/Response
```typescript
// POST /organizer/preview
interface OrganizerPreviewRequest {
regimenId: string;
numberOfDays: number; // Default: 7
}
interface OrganizerPreviewResponse {
regimenName: string;
numberOfDays: number;
items: {
medicineId: string;
medicineName: string;
quantityNeeded: number;
quantityAvailable: number;
isShort: boolean;
shortage: number;
cabinetBreakdown: {
cabinetItemId: string;
expirationDate: Date | null;
quantityToTake: number;
}[];
}[];
canFillCompletely: boolean;
hasShortages: boolean;
}
```
### Fill Request
```typescript
// POST /organizer/fill
interface OrganizerFillRequest {
regimenId: string;
numberOfDays: number;
allowPartial: boolean; // If false, reject when any medicine is short
notes?: string;
}
```
---
## Tasks
### 3.1 — Shared Types & Validation
- Add regimen types to `packages/shared/src/types/regimen.ts`
- Add organizer fill types to `packages/shared/src/types/organizer-fill.ts`
- Zod schemas:
- `CreateRegimenSchema`
- `UpdateRegimenSchema`
- `OrganizerPreviewSchema`
- `OrganizerFillSchema`
### 3.2 — Regimen CRUD
- `RegimensRepository` and `RegimensService`
- Standard CRUD scoped to `householdId` + `userId`
- On create/update: validate that all `medicineId` references exist in the medicine library
- Denormalize medicine fields (name, strength, unit, form)
### 3.3 — Frequency Multiplier Logic
```typescript
/**
* Calculate total pills needed for N days based on frequency.
*
* daily: dosage * numberOfDays
* twice_daily: dosage * 2 * numberOfDays
* three_times_daily: dosage * 3 * numberOfDays
* weekly: dosage * ceil(numberOfDays / 7)
* every_other_day: dosage * ceil(numberOfDays / 2)
* as_needed: 0 (excluded from organizer fills)
* custom: dosage * customFrequencyPerDay * numberOfDays
*/
function calculateQuantityNeeded(
medication: RegimenMedication,
numberOfDays: number,
): number;
```
### 3.4 — Organizer Fill Service
```typescript
class OrganizerService {
/**
* Preview: calculate what would happen without deducting.
* For each medicine in the regimen:
* 1. Calculate quantity needed (via frequency multiplier)
* 2. Check cabinet stock (via CabinetService.getAggregateSummary)
* 3. Plan FEFO deductions (earliest expiry first)
* 4. Flag shortages
*/
preview(householdId: string, regimenId: string, numberOfDays: number): Promise<OrganizerPreviewResponse>;
/**
* Fill: execute the preview plan.
* 1. Re-validate stock (may have changed since preview)
* 2. If allowPartial=false and any shortage, reject
* 3. Call CabinetService.deductStock for each medicine
* 4. Create OrganizerFill record
* 5. Return fill details
*/
fill(householdId: string, userId: string, request: OrganizerFillRequest): Promise<OrganizerFill>;
/**
* Undo: reverse a fill.
* 1. Verify fill is not already reversed
* 2. For each deduction, call CabinetService.restoreStock
* 3. Mark fill as reversed
*/
undoFill(householdId: string, fillId: string): Promise<OrganizerFill>;
}
```
### 3.5 — Burn Rate Calculation
```typescript
class BurnRateService {
/**
* For each medicine across all active regimens for a user:
* 1. Sum daily consumption: dosage * daily_frequency_multiplier
* 2. Get total cabinet stock for that medicine
* 3. daysUntilEmpty = floor(totalInCabinet / dailyConsumption)
* 4. Include earliest expiry date from cabinet
*
* Note: 'as_needed' frequency is excluded from burn rate.
* Note: If multiple users in household have regimens, each sees their own burn rate.
*/
calculateBurnRates(householdId: string, userId: string): Promise<BurnRate[]>;
}
```
### 3.6 — Web UI: Regimens
- `/regimens` page:
- List of user's regimens with active/inactive toggle
- Each regimen shows: name, medication count, active status
- Expand/click to see all medications with dosage details
- Add/Edit regimen form:
- Name, active toggle
- Medications list:
- Medicine autocomplete (from library)
- Dosage (number + unit)
- Frequency dropdown
- Time of day (optional)
- Instructions (optional)
- Add/remove medications
### 3.7 — Web UI: Pill Organizer
- `/organizer` page:
- **Fill organizer** section:
- Select regimen dropdown
- Number of days input (default: 7, adjustable)
- "Preview" button -> shows:
- Per-medicine breakdown: needed vs available
- Shortage warnings (highlighted)
- Which cabinet items will be drawn from (FEFO order)
- "Fill" button -> executes the fill, shows confirmation
- Option for partial fill when shortages exist
- **Burn rate** section:
- Table: medicine name, daily consumption, total in cabinet, days until empty
- Color-coded: green (>14 days), yellow (7-14 days), red (<7 days)
- Links to refill alerts (Phase 4)
- **Fill history** section:
- Recent fills with date, regimen, day count, status
- Expand to see per-medicine details
- "Undo" button on recent fills (with confirmation)
---
## Acceptance Criteria
- [ ] Can create and manage regimens with multiple medications
- [ ] Frequency multiplier correctly calculates quantities for all frequency types
- [ ] Preview accurately shows needed quantities and shortages
- [ ] Fill deducts from cabinet using FEFO (earliest expiry first)
- [ ] Partial fills work when `allowPartial` is true
- [ ] Fill is rejected when `allowPartial` is false and any medicine is short
- [ ] Undo fully restores cabinet quantities
- [ ] Undo is idempotent (cannot undo an already-reversed fill)
- [ ] Burn rate correctly accounts for all active regimens
- [ ] `as_needed` frequency is excluded from fill calculations and burn rate
- [ ] All queries scoped to `householdId`; regimens additionally scoped to `userId`
---
## Estimated Effort
Medium-large. The fill/undo transactional logic with FEFO, shortage handling, and burn rate calculations are the most complex parts. UI is moderately complex with the preview/fill flow.

View file

@ -0,0 +1,360 @@
# Phase 4 — Pharmacies, Prices & Refills
**Goal**: Track where you buy medicines, compare prices across pharmacies, and get automatic refill alerts when cabinet stock is running low. The Store and PriceRecord infrastructure built here is shared with food tracking (Phase 9).
**Depends on**: Phase 0, Phase 1 (medicines), Phase 2 (cabinet), Phase 3 (regimens — for burn rate)
---
## Deliverables
1. `Store` MongoDB schema and CRUD API (shared infrastructure)
2. `PriceRecord` schema for medicine price tracking
3. Price history and store comparison
4. Refill alerts based on burn rate
5. Refill list generation
6. Web UI: stores, price history, refill management
---
## Data Model
### Store Schema (Shared — used by both medicine and food domains)
```typescript
// packages/shared/src/types/store.ts
export interface Store {
id: string;
householdId: string;
name: string;
address?: string;
location?: {
lat: number;
lng: number;
};
url?: string;
notes?: string;
tags: string[]; // e.g., 'pharmacy', 'grocery', 'online', 'bulk', 'discount'
isActive: boolean;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
```
### PriceRecord Schema (Medicine)
```typescript
// packages/shared/src/types/medicine-price.ts
export interface MedicinePriceRecord {
id: string;
householdId: string;
medicineId: string;
medicineName: string; // Denormalized
storeId: string;
storeName: string; // Denormalized
price: number;
currency: string; // Default from household settings
quantity: number; // How many pills/units for this price
unit: DosageUnit;
pricePerUnit: number; // Computed: price / quantity
date: Date;
isInsurancePrice: boolean; // With insurance vs retail
notes?: string;
createdBy: string;
createdAt: Date;
}
```
### RefillAlert (Computed, not stored)
```typescript
// packages/shared/src/types/refill.ts
export interface RefillAlert {
medicineId: string;
medicineName: string;
medicineStrength: number;
medicineStrengthUnit: StrengthUnit;
daysUntilEmpty: number;
dailyConsumption: number;
currentStock: number;
suggestedQuantity: number; // Enough for N days (configurable, default 30)
lastKnownPrice?: {
price: number;
pricePerUnit: number;
storeName: string;
storeId: string;
date: Date;
};
cheapestOption?: {
price: number;
pricePerUnit: number;
storeName: string;
storeId: string;
date: Date;
};
}
export interface RefillList {
id: string;
householdId: string;
name: string;
items: RefillListItem[];
status: RefillListStatus;
preferredStoreId?: string;
totalEstimatedCost?: number;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
export interface RefillListItem {
id: string;
medicineId: string;
medicineName: string;
quantity: number;
unit: DosageUnit;
estimatedPrice?: number;
actualPrice?: number;
checked: boolean;
checkedAt?: Date;
addedToCabinet: boolean;
storeId?: string;
notes?: string;
}
export enum RefillListStatus {
ACTIVE = 'active',
SHOPPING = 'shopping',
COMPLETED = 'completed',
ARCHIVED = 'archived',
}
```
### MongoDB Indexes
```javascript
// Store
{ householdId: 1, name: 1 }
{ householdId: 1, tags: 1 }
// MedicinePriceRecord
{ householdId: 1, medicineId: 1, storeId: 1, date: -1 }
{ householdId: 1, medicineId: 1, date: -1 }
{ householdId: 1, storeId: 1, date: -1 }
// RefillList
{ householdId: 1, status: 1 }
{ householdId: 1, createdAt: -1 }
```
---
## API Endpoints
### StoresModule (Shared)
| Method | Path | Description | Auth |
| ------ | ------------- | ------------------------- | ------ |
| GET | `/stores` | List stores for household | member |
| GET | `/stores/:id` | Get single store | member |
| POST | `/stores` | Add a store | member |
| PATCH | `/stores/:id` | Update store | member |
| DELETE | `/stores/:id` | Deactivate store | admin |
### MedicinePricesModule
| Method | Path | Description | Auth |
| ------ | -------------------------------------- | --------------------------------- | ------ |
| POST | `/medicine-prices` | Record a price | member |
| GET | `/medicine-prices/history/:medicineId` | Price history for a medicine | member |
| GET | `/medicine-prices/compare/:medicineId` | Compare stores for a medicine | member |
| GET | `/medicine-prices/analytics` | Spending analytics | member |
### RefillsModule
| Method | Path | Description | Auth |
| ------ | ----------------------------------- | ---------------------------------------- | ------ |
| GET | `/refills/alerts` | Get refill alerts (medicines running low)| member |
| POST | `/refills/lists` | Create refill list (manual or from alerts)| member |
| GET | `/refills/lists` | List refill lists | member |
| GET | `/refills/lists/:id` | Get refill list | member |
| PATCH | `/refills/lists/:id` | Update refill list | member |
| PATCH | `/refills/lists/:id/items/:itemId` | Check off / update item | member |
| POST | `/refills/lists/:id/add-to-cabinet` | Move checked items to cabinet | member |
| GET | `/refills/lists/:id/store-comparison`| Best store for this list | member |
### Query Parameters
```
# GET /stores
?tags=pharmacy # Filter by tags
&search=walgreens # Name search
# GET /medicine-prices/history/:medicineId
?storeId=abc123 # Filter by store
&startDate=2026-01-01 # Date range
&endDate=2026-03-27
&limit=50
# GET /refills/alerts
?thresholdDays=7 # Alert when <= N days of stock remain (default: 7)
&userId=abc123 # Filter by user's regimens
```
---
## Tasks
### 4.1 — Shared Types & Validation
- Add store types to `packages/shared/src/types/store.ts`
- Add medicine price types to `packages/shared/src/types/medicine-price.ts`
- Add refill types to `packages/shared/src/types/refill.ts`
- Zod schemas for all create/update operations
### 4.2 — Stores CRUD (Shared Infrastructure)
- `packages/api/src/modules/stores/`
- Standard CRUD, scoped to `householdId`
- Tag-based filtering (pharmacy, grocery, online, etc.)
- This module is used by both medicine and food domains
### 4.3 — Medicine Price Service
```typescript
class MedicinePriceService {
/** Record a price, computing pricePerUnit */
recordPrice(data: CreateMedicinePriceRecord): Promise<MedicinePriceRecord>;
/** Get price history for a medicine, optionally filtered by store */
getPriceHistory(medicineId: string, householdId: string, options?: {
storeId?: string;
startDate?: Date;
endDate?: Date;
limit?: number;
}): Promise<MedicinePriceRecord[]>;
/** Compare current prices across stores for a medicine */
compareStores(medicineId: string, householdId: string): Promise<StoreComparison[]>;
/** Estimate price based on most recent record */
estimatePrice(medicineId: string, householdId: string, storeId?: string): Promise<number | null>;
/** Spending analytics over time */
getAnalytics(householdId: string, period: 'month' | 'quarter' | 'year'): Promise<MedicineSpendingAnalytics>;
}
```
### 4.4 — Refill Alert Service
```typescript
class RefillAlertService {
/**
* For each medicine in the user's active regimens:
* 1. Get burn rate from BurnRateService (Phase 3)
* 2. If daysUntilEmpty <= thresholdDays, create alert
* 3. Attach last known price + cheapest store option
* 4. Calculate suggested quantity (enough for configurable days, default 30)
*/
getAlerts(householdId: string, userId: string, thresholdDays?: number): Promise<RefillAlert[]>;
}
```
### 4.5 — Refill Lists
- CRUD for refill lists
- `POST /refills/lists` with optional `fromAlerts: true` to auto-populate from current alerts
- Each item can have an estimated price (from price history) and an actual price (entered when purchased)
- Store comparison: for each item, find cheapest store based on recent price records
### 4.6 — Refill to Cabinet Flow
- `POST /refills/lists/:id/add-to-cabinet`:
- For each checked item with `addedToCabinet: false`:
- Create a `CabinetItem` (status: active, purchaseDate: today)
- If `actualPrice` was entered, create a `MedicinePriceRecord`
- Mark `addedToCabinet: true`
- Return summary: `{ addedCount, priceRecordsCreated }`
### 4.7 — Price Analytics
```typescript
interface MedicineSpendingAnalytics {
/** Total spending per period */
spendingOverTime: { period: string; total: number }[];
/** Most expensive medicines */
topBySpending: {
medicineId: string;
medicineName: string;
totalSpent: number;
avgPricePerUnit: number;
}[];
/** Per-store spending */
spendingByStore: {
storeId: string;
storeName: string;
totalSpent: number;
purchaseCount: number;
}[];
/** Price trend alerts (significant increases) */
priceAlerts: {
medicineId: string;
medicineName: string;
storeName: string;
previousPrice: number;
currentPrice: number;
changePercent: number;
}[];
}
```
### 4.8 — Web UI: Pharmacies & Prices
- `/stores` page:
- Store list with CRUD
- Filter by tags (pharmacy, grocery, etc.)
- Per-store summary: total spent, last visit, item count
- `/medicine-prices` page:
- Medicine search -> price history line chart (per store, color-coded)
- Store comparison table for selected medicine
- Spending over time bar chart
- Price alert panel
### 4.9 — Web UI: Refill Management
- `/refills` page:
- **Alerts section**: medicines running low
- Card per medicine: name, days remaining, suggested quantity, cheapest store
- "Generate Refill List" button -> creates list from all alerts
- **Refill lists section**:
- Active lists at top, completed/archived below
- List detail view:
- Items with checkbox, medicine name, quantity, estimated price
- Check off: optionally enter actual price
- Store comparison panel
- "Done Shopping" -> prompts "Add items to cabinet?"
- **Dashboard widget**: refill alert count badge, medicines needing refill soon
---
## Acceptance Criteria
- [ ] Can create and manage stores with tags
- [ ] Can record medicine prices and view price history
- [ ] Store comparison shows cheapest option per medicine
- [ ] Refill alerts correctly identify medicines running low based on burn rate
- [ ] Refill lists can be auto-generated from alerts
- [ ] Checked refill items can be added to cabinet in one action
- [ ] Price analytics show spending trends
- [ ] Store infrastructure is reusable for food tracking (Phase 9)
- [ ] All queries scoped to `householdId`
---
## Estimated Effort
Medium-large. Store/price infrastructure, refill alert logic, and the refill-to-cabinet flow involve significant work. The store comparison and analytics add moderate complexity.

View file

@ -0,0 +1,254 @@
# Phase 5 — Product Library
**Goal**: A searchable catalog of food products with nutrition data, reusable across the entire food tracking domain. Products are the atomic building blocks for recipes, pantry items, and shopping lists.
**Depends on**: Phase 0 (auth, households, shared types). Can reuse Store infrastructure from Phase 4.
---
## Deliverables
1. `Product` MongoDB schema and full CRUD API
2. Full-text search with filters
3. Barcode lookup via Open Food Facts
4. Bulk import (CSV/JSON)
5. Product library web UI (search, add, edit)
6. LLM provider interface (`ILlmProvider`) with no-op implementation
7. "Smart Add" endpoint placeholder
---
## Data Model
### Product Schema
```typescript
// packages/shared/src/types/product.ts
export interface Product {
id: string;
householdId: string;
name: string;
brand?: string;
barcode?: string;
category: ProductCategory;
servingSize: number;
servingUnit: ServingUnit;
nutrition: NutritionInfo;
tags: string[];
imageUrl?: string;
isPublic: boolean; // Visible to all households (for shared catalog)
source: ProductSource; // 'manual' | 'barcode_lookup' | 'llm' | 'import'
createdBy: string; // userId
createdAt: Date;
updatedAt: Date;
}
export interface NutritionInfo {
calories: number; // kcal per serving
protein: number; // grams
carbs: number; // grams
fat: number; // grams
fiber?: number; // grams
sugar?: number; // grams
sodium?: number; // mg
saturatedFat?: number; // grams
cholesterol?: number; // mg
}
export enum ProductCategory {
DAIRY = 'dairy',
MEAT = 'meat',
POULTRY = 'poultry',
SEAFOOD = 'seafood',
FRUITS = 'fruits',
VEGETABLES = 'vegetables',
GRAINS = 'grains',
LEGUMES = 'legumes',
NUTS_SEEDS = 'nuts_seeds',
OILS_FATS = 'oils_fats',
CONDIMENTS = 'condiments',
SPICES = 'spices',
BEVERAGES = 'beverages',
SNACKS = 'snacks',
FROZEN = 'frozen',
CANNED = 'canned',
BAKERY = 'bakery',
DELI = 'deli',
SUPPLEMENTS = 'supplements',
OTHER = 'other',
}
export enum ServingUnit {
GRAMS = 'g',
MILLILITERS = 'ml',
OUNCES = 'oz',
CUPS = 'cup',
TABLESPOONS = 'tbsp',
TEASPOONS = 'tsp',
PIECES = 'piece',
SLICES = 'slice',
}
export enum ProductSource {
MANUAL = 'manual',
BARCODE_LOOKUP = 'barcode_lookup',
LLM = 'llm',
IMPORT = 'import',
}
```
### MongoDB Indexes
```javascript
// Text index for search
{ name: 'text', brand: 'text', tags: 'text' }
// Compound indexes
{ householdId: 1, category: 1 }
{ householdId: 1, barcode: 1 } // unique within household
{ householdId: 1, name: 1, brand: 1 } // near-unique for dedup
```
---
## API Endpoints
### ProductsModule
| Method | Path | Description | Auth |
| ------ | ------------------------- | ------------------------------------------- | ------ |
| GET | `/products` | List/search products (paginated) | member |
| GET | `/products/:id` | Get single product | member |
| POST | `/products` | Create product | member |
| PATCH | `/products/:id` | Update product | member |
| DELETE | `/products/:id` | Soft-delete product | admin |
| GET | `/products/barcode/:code` | Lookup by barcode (local → Open Food Facts) | member |
| POST | `/products/import` | Bulk import from CSV/JSON | admin |
| POST | `/products/smart-add` | LLM-powered add from text/image | member |
### Query Parameters for GET `/products`
```
?q=chicken # Full-text search
&category=meat # Filter by category
&tags=organic,fresh # Filter by tags (AND)
&cursor=abc123 # Cursor-based pagination
&limit=20 # Page size (max 100)
&sort=name|-updatedAt # Sort field, prefix - for desc
```
### Response Shape
```typescript
interface PaginatedResponse<T> {
data: T[];
pagination: {
cursor: string | null; // null = last page
hasMore: boolean;
total: number;
};
}
```
---
## Tasks
### 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
### 5.2 — Mongoose Schema & Repository
- `packages/api/src/modules/products/schemas/product.schema.ts`
- `ProductRepository` with:
- `findByHousehold(householdId, query)` — supports text search, filters, cursor pagination
- `findByBarcode(householdId, barcode)`
- `create(data)`
- `update(id, householdId, data)`
- `softDelete(id, householdId)`
- `bulkCreate(items[])`
### 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'`
### 5.4 — LLM Provider Interface
- `packages/api/src/modules/llm/interfaces/llm-provider.interface.ts`:
```typescript
export interface ILlmProvider {
extractNutrition(input: {
text?: string;
image?: Buffer;
}): Promise<NutritionExtractionResult | null>;
parseRecipe(text: string): Promise<ParsedRecipe | null>;
parseRecipeFromUrl(url: string): Promise<ParsedRecipe | null>;
parseReceipt(image: Buffer): Promise<ParsedReceipt | null>;
suggestMealPlan(context: MealPlanContext): Promise<MealPlanSuggestion | null>;
parseNaturalLanguage(text: string): Promise<StructuredAction | null>;
}
export const LLM_PROVIDER = Symbol('LLM_PROVIDER');
```
- `NoOpLlmProvider`: implements interface, returns `null` for all methods, logs a warning
- `LlmModule`: provides `LLM_PROVIDER` via factory, selectable by env var `LLM_PROVIDER_TYPE`
### 5.5 — Smart Add Endpoint
- `POST /products/smart-add` accepts `{ text?: string, image?: file }`
- Calls `ILlmProvider.extractNutrition()`
- If LLM returns data, pre-fill a product and return to client for review (not auto-saved)
- If LLM unavailable (`NoOpLlmProvider`), return `{ available: false, message: 'LLM not configured' }`
### 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, ...`
### 5.7 — Web UI: Product Library
- `/products` page:
- Search bar with debounced full-text search
- Category filter dropdown
- Tag filter chips
- Product grid/list view (toggle)
- Each product card shows: name, brand, category icon, calories/serving
- Add/Edit product modal:
- Form fields for all product properties
- Nutrition input section with per-serving values
- Barcode field with "Lookup" button
- "Smart Add" tab (text input or image upload)
- Import dialog: file upload with preview 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
- [ ] 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
- [ ] All product queries are scoped to `householdId`
---
## Estimated Effort
Medium. Straightforward CRUD with search; barcode integration adds some complexity.

View file

@ -0,0 +1,237 @@
# Phase 6 — Recipe Management
**Goal**: Enter, import, and manage recipes. Auto-calculate nutrition from product library ingredients. Highlight nutritional warnings. This phase ties into the product library (Phase 5) and will be consumed by meal planning (Phase 8).
**Depends on**: Phase 0, Phase 5
---
## Deliverables
1. `Recipe` MongoDB schema and full CRUD API
2. Automatic nutrition calculation from ingredient list
3. Nutritional warning generation
4. Recipe scaling (adjust servings)
5. Recipe import via LLM (plain text → structured)
6. Recipe editor web UI with live nutrition sidebar
---
## Data Model
### Recipe Schema
```typescript
// packages/shared/src/types/recipe.ts
export interface Recipe {
id: string;
householdId: string;
name: string;
description?: string;
servings: number;
prepTime?: number; // minutes
cookTime?: number; // minutes
totalTime?: number; // minutes (auto-calculated or manual)
ingredients: RecipeIngredient[];
steps: RecipeStep[];
tags: string[]; // e.g., 'vegetarian', 'quick', 'meal-prep'
cuisine?: string; // e.g., 'Italian', 'Japanese'
imageUrl?: string;
source?: RecipeSource;
totalNutrition: NutritionInfo; // Denormalized, computed on save
perServingNutrition: NutritionInfo; // Denormalized, computed on save
warnings: NutritionWarning[]; // Computed on save
isFavorite: boolean;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
export interface RecipeIngredient {
productId: string; // Reference to Product
productName: string; // Denormalized for display
quantity: number;
unit: ServingUnit;
preparation?: string; // e.g., 'diced', 'minced', 'melted'
isOptional: boolean;
nutritionContribution: NutritionInfo; // Per-ingredient computed nutrition
}
export interface RecipeStep {
order: number;
instruction: string;
duration?: number; // minutes
tip?: string;
}
export interface RecipeSource {
type: 'manual' | 'url' | 'llm_import' | 'text_import';
url?: string;
importedAt?: Date;
}
export enum NutritionWarning {
HIGH_CALORIES = 'high_calories', // > 800 kcal/serving
HIGH_SODIUM = 'high_sodium', // > 1500mg/serving
HIGH_SUGAR = 'high_sugar', // > 25g/serving
HIGH_SATURATED_FAT = 'high_saturated_fat', // > 13g/serving
LOW_PROTEIN = 'low_protein', // < 10g/serving
LOW_FIBER = 'low_fiber', // < 3g/serving
HIGH_CHOLESTEROL = 'high_cholesterol', // > 200mg/serving
}
```
### MongoDB Indexes
```javascript
{ householdId: 1, name: 'text', tags: 'text', cuisine: 'text' }
{ householdId: 1, 'ingredients.productId': 1 } // Find recipes using a product
{ householdId: 1, tags: 1 }
{ householdId: 1, isFavorite: 1 }
```
---
## API Endpoints
### RecipesModule
| Method | Path | Description | Auth |
| ------ | -------------------------------- | --------------------------------------- | ------ |
| GET | `/recipes` | List/search recipes (paginated) | member |
| GET | `/recipes/:id` | Get single recipe | member |
| POST | `/recipes` | Create recipe | member |
| PATCH | `/recipes/:id` | Update recipe | member |
| DELETE | `/recipes/:id` | Soft-delete recipe | admin |
| POST | `/recipes/:id/scale` | Get scaled version (preview, not saved) | member |
| POST | `/recipes/import-text` | Import from plain text via LLM | member |
| POST | `/recipes/import-url` | Import from URL via LLM | member |
| GET | `/recipes/by-product/:productId` | Find recipes using a specific product | member |
### Query Parameters for GET `/recipes`
```
?q=pasta # Full-text search
&tags=vegetarian,quick # Filter by tags
&cuisine=Italian # Filter by cuisine
&maxCalories=600 # Filter by per-serving calories
&isFavorite=true # Favorites only
&cursor=abc123
&limit=20
```
---
## Tasks
### 6.1 — Shared Types & Validation
- Add recipe types to `packages/shared/src/types/recipe.ts`
- Zod schemas:
- `CreateRecipeSchema` — ingredients must reference valid productIds
- `UpdateRecipeSchema` — partial
- `ScaleRecipeSchema``{ targetServings: number }`
- `ImportRecipeTextSchema``{ text: string }`
- `ImportRecipeUrlSchema``{ url: string }`
### 6.2 — Nutrition Calculation Service
- `NutritionCalculatorService`:
```typescript
class NutritionCalculatorService {
/**
* For each ingredient:
* 1. Lookup the product by productId
* 2. Convert ingredient quantity/unit to product's servingUnit
* 3. Calculate nutrition proportionally: (ingredient_qty / serving_size) * nutrition_per_serving
* 4. Sum across all ingredients → totalNutrition
* 5. Divide by servings → perServingNutrition
*/
calculateRecipeNutrition(
ingredients: RecipeIngredient[],
servings: number,
): RecipeNutritionResult;
/**
* Check per-serving nutrition against warning thresholds
*/
generateWarnings(perServingNutrition: NutritionInfo): NutritionWarning[];
}
```
- 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**
### 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
- On product nutrition update (Phase 5 edit), trigger background recalculation:
- Find all recipes where `ingredients[].productId == updatedProductId`
- Recalculate each recipe's nutrition
### 6.4 — Recipe Scaling
- `POST /recipes/:id/scale` with `{ targetServings: number }`:
- Returns a scaled **preview** (not persisted) with adjusted ingredient quantities and recalculated nutrition
- `scaledQuantity = originalQuantity * (targetServings / originalServings)`
### 6.5 — Recipe Import (LLM)
- `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
- `POST /recipes/import-url`:
- Calls `ILlmProvider.parseRecipeFromUrl(url)`
- Same flow as text import
- With `NoOpLlmProvider`: returns `{ available: false }`
### 6.6 — Web UI: Recipe Management
- `/recipes` page:
- Search bar, tag and cuisine filters
- Recipe card grid: image, name, time, calories/serving, warning badges
- Favorites tab
- `/recipes/new` and `/recipes/:id/edit`:
- Recipe metadata form (name, description, servings, times, cuisine, tags)
- Ingredient editor:
- Autocomplete from product library
- Quantity + unit inputs
- "Add ingredient" button, drag-to-reorder
- Per-ingredient nutrition shown inline
- Steps editor: ordered text areas, optional duration per step
- **Live nutrition sidebar**: updates as ingredients are added/changed
- Shows total and per-serving macros
- Warning badges with explanations
- "Scale" button: adjust servings in sidebar to see scaled amounts
- `/recipes/:id` detail page:
- Full recipe view with ingredients, steps, nutrition panel
- "Import from text" and "Import from URL" buttons in recipe list page
---
## Acceptance Criteria
- [ ] Can create a recipe with ingredients linked to products
- [ ] Nutrition is automatically calculated and stored on the recipe
- [ ] Warnings are generated for recipes exceeding thresholds
- [ ] Recipe scaling returns correctly adjusted quantities
- [ ] Editing a product's nutrition triggers recipe recalculation
- [ ] Text/URL import endpoint delegates to LLM provider interface
- [ ] Web UI shows live nutrition as ingredients are added
- [ ] Full-text search finds recipes by name, tags, cuisine
---
## Estimated Effort
Medium. Nutrition calculation logic and unit conversion require careful implementation. UI is moderately complex with the live sidebar.

View file

@ -0,0 +1,346 @@
# Phase 7 — Pantry & Fridge Tracking
**Goal**: Track the lifecycle of physical food items — purchase, opening, preparation, consumption, or disposal. Estimate freshness/spoilage timelines. Provide a real-time dashboard of what's in the household's storage.
**Depends on**: Phase 0, Phase 5
---
## Deliverables
1. `PantryItem` and `FreshnessRule` MongoDB schemas
2. Full CRUD API with status transition workflow
3. Freshness estimation and urgency scoring
4. Scheduled freshness check job (cron) with in-app notifications
5. Pantry dashboard web UI with color-coded freshness
6. Waste analysis history
---
## Data Model
### PantryItem Schema
```typescript
// packages/shared/src/types/pantry.ts
export interface PantryItem {
id: string;
householdId: string;
productId: string;
productName: string; // Denormalized
storageLocation: StorageLocation;
quantity: number;
unit: ServingUnit;
purchaseDate: Date;
expirationDate?: Date; // From packaging, if known
openedDate?: Date;
preparedDate?: Date;
status: ItemStatus;
freshnessEstimate: FreshnessEstimate;
notes?: string;
purchasePrice?: number; // Links to grocery tracking (Phase 9)
storeId?: string; // Where it was bought
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
export enum StorageLocation {
PANTRY = 'pantry',
FRIDGE = 'fridge',
FREEZER = 'freezer',
COUNTER = 'counter',
}
export enum ItemStatus {
SEALED = 'sealed',
OPENED = 'opened',
PREPARED = 'prepared',
CONSUMED = 'consumed',
DISCARDED = 'discarded',
EXPIRED = 'expired',
}
export interface FreshnessEstimate {
estimatedExpiryDate: Date; // Computed from rules
daysRemaining: number; // Computed
urgency: FreshnessUrgency; // Computed
source: 'packaging' | 'rule' | 'manual';
}
export enum FreshnessUrgency {
FRESH = 'fresh', // > 5 days
USE_SOON = 'use_soon', // 2-5 days
URGENT = 'urgent', // 0-2 days
CHECK = 'check', // Past estimated date, may still be ok
EXPIRED = 'expired', // Way past date
}
```
### FreshnessRule Schema
```typescript
// packages/shared/src/types/freshness.ts
export interface FreshnessRule {
id: string;
householdId?: string; // null = system default
category: ProductCategory;
storageLocation: StorageLocation;
shelfLifeDays: number; // When sealed
openedLifeDays: number; // After opening
freezerLifeDays?: number; // If moved to freezer
spoilageSignsToCheck: string[]; // e.g., ['smell', 'discoloration', 'texture change']
tips?: string; // Storage tips
source: 'system' | 'household'; // System defaults vs household overrides
}
```
### Status Transition Rules
```
┌──────────┐
│ SEALED │
└─────┬─────┘
┌────────┼────────┐
▼ ▼ ▼
┌─────────┐ ┌──────┐ ┌──────────┐
│ OPENED │ │CONSUMED│ │DISCARDED │
└────┬────┘ └──────┘ └──────────┘
┌────┼────────┐
▼ ▼ ▼
┌──────┐┌──────────┐┌──────────┐
│PREPARED│ │CONSUMED │ │DISCARDED │
└───┬──┘ └──────────┘└──────────┘
├──────────┐
▼ ▼
┌──────────┐┌──────────┐
│ CONSUMED ││ DISCARDED│
└──────────┘└──────────┘
```
Valid transitions:
- `sealed → opened | consumed | discarded`
- `opened → prepared | consumed | discarded`
- `prepared → consumed | discarded`
- Any status → `expired` (set by system cron)
### MongoDB Indexes
```javascript
{ householdId: 1, status: 1, 'freshnessEstimate.urgency': 1 }
{ householdId: 1, storageLocation: 1, status: 1 }
{ householdId: 1, productId: 1, status: 1 }
{ householdId: 1, 'freshnessEstimate.estimatedExpiryDate': 1 } // For cron job
{ 'freshnessRule.category': 1, 'freshnessRule.storageLocation': 1 } // For rule lookup
```
---
## API Endpoints
### PantryModule
| Method | Path | Description | Auth |
| ------ | -------------------------- | ------------------------------------------------------------- | ------ |
| GET | `/pantry` | List pantry items (filtered, paginated) | member |
| GET | `/pantry/:id` | Get single item | member |
| POST | `/pantry` | Add item to pantry | member |
| PATCH | `/pantry/:id` | Update item details | member |
| POST | `/pantry/:id/transition` | Change status (open, consume, discard, etc.) | member |
| POST | `/pantry/batch-transition` | Bulk status change (e.g., mark all as consumed after cooking) | member |
| DELETE | `/pantry/:id` | Hard delete (admin) | admin |
| GET | `/pantry/expiring-soon` | Items expiring within N days | member |
| GET | `/pantry/stats` | Waste analysis summary | member |
### FreshnessRulesModule
| Method | Path | Description | Auth |
| ------ | ---------------------- | -------------------------------------------- | ------ |
| GET | `/freshness-rules` | List rules (system + household overrides) | member |
| POST | `/freshness-rules` | Create household override | admin |
| PATCH | `/freshness-rules/:id` | Update household rule | admin |
| DELETE | `/freshness-rules/:id` | Remove household override (revert to system) | admin |
### Query Parameters for GET `/pantry`
```
?storageLocation=fridge # Filter by location
&status=sealed,opened # Filter by status (comma-separated)
&urgency=urgent,use_soon # Filter by freshness urgency
&productId=abc123 # Filter by product
&sort=-freshnessEstimate.daysRemaining # Sort by urgency (most urgent first)
&cursor=abc123
&limit=20
```
---
## Tasks
### 7.1 — Shared Types & Validation
- Add pantry types to `packages/shared/src/types/pantry.ts`
- Add freshness types to `packages/shared/src/types/freshness.ts`
- Zod schemas:
- `CreatePantryItemSchema`
- `UpdatePantryItemSchema`
- `TransitionPantryItemSchema``{ status: ItemStatus, date?: Date, notes?: string }`
- `CreateFreshnessRuleSchema`
### 7.2 — Freshness Rule Seed Data
- Seed `FreshnessRule` collection with defaults based on USDA/StillTasty guidelines:
| Category | Location | Sealed (days) | Opened (days) | Freezer (days) |
| ---------- | -------- | ------------- | ------------- | -------------- |
| Dairy | Fridge | 14 | 7 | 90 |
| Meat | Fridge | 3 | 2 | 180 |
| Poultry | Fridge | 2 | 1 | 270 |
| Seafood | Fridge | 2 | 1 | 180 |
| Fruits | Counter | 7 | 3 | 270 |
| Vegetables | Fridge | 7 | 4 | 270 |
| Grains | Pantry | 180 | 90 | 365 |
| Legumes | Pantry | 365 | 7 | 365 |
| Bakery | Counter | 5 | 3 | 90 |
| ... | ... | ... | ... | ... |
- Household can override any rule
### 7.3 — Freshness Calculation Service
```typescript
class FreshnessService {
/**
* Given a pantry item and its applicable freshness rule:
* 1. If packaging expirationDate exists, use it
* 2. Else compute: purchaseDate + shelfLifeDays (sealed) or openedDate + openedLifeDays (opened)
* 3. If in freezer, use freezerLifeDays from purchaseDate
* 4. Calculate daysRemaining = estimatedExpiryDate - today
* 5. Map to urgency: >5 = FRESH, 2-5 = USE_SOON, 0-2 = URGENT, <0 = CHECK/EXPIRED
*/
calculateFreshness(item: PantryItem, rule: FreshnessRule): FreshnessEstimate;
/**
* Find the most specific rule: household override > system default
* Match by category + storageLocation
*/
findApplicableRule(
householdId: string,
category: ProductCategory,
location: StorageLocation,
): FreshnessRule;
}
```
### 7.4 — Status Transition Service
```typescript
class PantryTransitionService {
/**
* Validate transition is allowed, apply side effects:
* - sealed → opened: set openedDate, recalculate freshness with openedLifeDays
* - * → consumed: record consumption date, update quantity
* - * → discarded: record discard date, log for waste analysis
*/
transition(item: PantryItem, newStatus: ItemStatus, metadata?: TransitionMetadata): PantryItem;
}
```
### 7.5 — Freshness Cron Job
- NestJS `@Cron('0 6 * * *')` (daily at 6 AM, configurable):
1. Query all active pantry items (status: sealed/opened/prepared)
2. Recalculate freshness estimates
3. Items past expiry → update status to `expired`
4. Items in `urgent` or `check` → create in-app notifications
- Notification model (simple for now, expand for push in mobile phase):
```typescript
export interface Notification {
id: string;
householdId: string;
userId?: string; // null = all household members
type: 'freshness_warning' | 'item_expired';
title: string;
body: string;
relatedEntityId: string; // PantryItem ID
isRead: boolean;
createdAt: Date;
}
```
### 7.6 — Waste Analysis
- `GET /pantry/stats` returns:
```typescript
interface WasteStats {
period: { start: Date; end: Date };
totalItemsConsumed: number;
totalItemsDiscarded: number;
wastePercentage: number; // discarded / (consumed + discarded) * 100
topWastedCategories: { category: ProductCategory; count: number }[];
topWastedProducts: { productId: string; productName: string; count: number }[];
trendVsPreviousPeriod: number; // % change
}
```
- Query parameters: `?period=week|month|quarter|year`
### 7.7 — WebSocket Events (Initial)
- Set up NestJS `@WebSocketGateway` with household-scoped rooms
- Events:
- `pantry:item-added` — when a new item is added
- `pantry:item-updated` — when item status changes
- `pantry:freshness-alert` — when cron detects urgent items
- Frontend subscribes on pantry page for real-time updates across household members
### 7.8 — Web UI: Pantry Dashboard
- `/pantry` page:
- **Storage tabs**: Fridge | Freezer | Pantry | Counter | All
- **View modes**: Grid (cards) | List (table)
- Each item shows:
- Product name, quantity
- Freshness indicator: color-coded chip (green/yellow/orange/red)
- Days remaining
- Status badge
- Quick-action buttons: Open | Consume | Discard
- **Sort**: by urgency (default), name, purchase date
- **Filter**: by urgency level, category
- Floating "Add Item" button → modal:
- Product autocomplete (from library)
- Storage location picker
- Purchase date (default today)
- Expiration date (optional, from packaging)
- Quantity + unit
- `/pantry/stats` page:
- Waste percentage gauge
- Top wasted categories bar chart
- Trend line chart (weekly waste over time)
- **Notification bell** in top bar: shows freshness warnings, mark as read
---
## Acceptance Criteria
- [ ] Can add items to pantry linked to products
- [ ] Freshness estimate is calculated on creation and updates
- [ ] Status transitions follow valid workflow rules
- [ ] Daily cron job flags expiring items and creates notifications
- [ ] Pantry dashboard shows items color-coded by freshness urgency
- [ ] Waste stats endpoint returns correct aggregation
- [ ] WebSocket broadcasts pantry changes to household members
- [ ] Freshness rules can be overridden per household
- [ ] Items sorted by urgency show most critical first
---
## Estimated Effort
Medium-large. Freshness logic, cron job, notifications, and WebSocket add significant complexity beyond basic CRUD.

View file

@ -0,0 +1,269 @@
# Phase 8 — Meal Planning & Waste Reduction
**Goal**: Enable weekly meal planning with automatic nutrition tracking vs targets. The core feature is a **suggestion engine** that recommends recipes prioritizing ingredients already in the pantry (especially those expiring soon), reducing food waste while maintaining nutritional balance.
**Depends on**: Phase 5 (products), Phase 6 (recipes), Phase 7 (pantry)
---
## Deliverables
1. `MealPlan` and `NutritionTarget` MongoDB schemas
2. Meal plan CRUD API with daily/weekly views
3. Recipe suggestion engine (algorithmic, not LLM-dependent)
4. Nutrition targets per user with daily tracking
5. Shopping gap analysis (what's needed beyond pantry)
6. Meal plan web UI with weekly calendar and suggestion panel
---
## Data Model
### MealPlan Schema
```typescript
// packages/shared/src/types/meal-plan.ts
export interface MealPlan {
id: string;
householdId: string;
weekStartDate: Date; // Monday of the planning week
days: MealPlanDay[];
status: MealPlanStatus;
shoppingListId?: string; // Auto-generated shopping list (Phase 9 link)
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
export interface MealPlanDay {
date: Date;
meals: PlannedMeal[];
dailyNutritionTotal: NutritionInfo; // Computed
}
export interface PlannedMeal {
id: string; // UUID for drag-and-drop reference
type: MealType;
recipeId?: string; // Linked recipe
recipeName: string; // Denormalized
servings: number;
customName?: string; // For non-recipe meals
customNutrition?: NutritionInfo; // Manual override for non-recipe meals
perServingNutrition: NutritionInfo; // From recipe or custom
notes?: string;
}
export enum MealType {
BREAKFAST = 'breakfast',
LUNCH = 'lunch',
DINNER = 'dinner',
SNACK = 'snack',
}
export enum MealPlanStatus {
DRAFT = 'draft',
ACTIVE = 'active',
COMPLETED = 'completed',
}
```
### NutritionTarget Schema
```typescript
// packages/shared/src/types/nutrition-target.ts
export interface NutritionTarget {
id: string;
userId: string;
householdId: string;
dailyCalories: number;
proteinG: number;
carbsG: number;
fatG: number;
fiberG?: number;
sodiumMg?: number;
sugarG?: number;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
```
### MongoDB Indexes
```javascript
{ householdId: 1, weekStartDate: 1 } // unique per household per week
{ householdId: 1, status: 1 }
{ householdId: 1, 'days.meals.recipeId': 1 } // Find plans using a recipe
```
---
## API Endpoints
### MealPlanModule
| Method | Path | Description | Auth |
| ------ | ------------------------------ | ---------------------------------------------- | ------ |
| GET | `/meal-plans` | List meal plans (paginated) | member |
| GET | `/meal-plans/current` | Get current week's plan | member |
| GET | `/meal-plans/:id` | Get specific plan | member |
| POST | `/meal-plans` | Create new week plan | member |
| PATCH | `/meal-plans/:id` | Update plan (add/move/remove meals) | member |
| DELETE | `/meal-plans/:id` | Delete plan (draft only) | admin |
| POST | `/meal-plans/:id/activate` | Set plan as active | member |
| GET | `/meal-plans/:id/shopping-gap` | What's needed beyond current pantry | member |
| GET | `/meal-plans/suggestions` | Get recipe suggestions for current pantry | member |
| POST | `/meal-plans/suggest-with-llm` | LLM-powered meal plan generation (placeholder) | member |
### NutritionTargetModule
| Method | Path | Description | Auth |
| ------ | ------------------------ | -------------------------------- | ------ |
| GET | `/nutrition-targets` | Get current user's active target | member |
| POST | `/nutrition-targets` | Set nutrition targets | member |
| PATCH | `/nutrition-targets/:id` | Update targets | member |
---
## Tasks
### 8.1 — Shared Types & Validation
- Add all types above to `packages/shared`
- Zod schemas for create/update operations
- Meal plan day validation (7 days per plan, valid dates)
### 8.2 — Meal Plan CRUD
- Standard CRUD with computed `dailyNutritionTotal` per day:
- Sum `perServingNutrition * servings` for all meals in each day
- On meal plan creation: default to 7 empty days (MondaySunday)
- On update: support granular operations:
- `addMeal(dayIndex, meal)`
- `removeMeal(dayIndex, mealId)`
- `moveMeal(fromDay, toDay, mealId)` — for drag-and-drop
- `updateMeal(dayIndex, mealId, updates)`
### 8.3 — Recipe Suggestion Engine (Core Algorithm)
This is the **key differentiating feature** — algorithmic, no LLM required.
```typescript
class RecipeSuggestionService {
/**
* Score and rank recipes based on pantry state and user preferences.
*
* Input:
* - Current pantry items (with freshness urgency)
* - Recipe catalog for the household
* - User's nutrition targets (optional)
* - Already planned meals this week (to avoid repetition)
*
* Scoring per recipe:
* ingredientCoverageScore (0-40 pts): % of ingredients available in pantry
* freshnessUrgencyScore (0-30 pts): bonus for using urgent/use-soon items
* nutritionBalanceScore (0-20 pts): how well it complements the day's existing meals vs targets
* varietyScore (0-10 pts): penalty for recently planned recipes
*
* Output per suggestion:
* - recipe (id, name, perServingNutrition)
* - score (total)
* - availableIngredients[]: items from pantry that match
* - missingIngredients[]: items not in pantry (with estimated cost from Phase 9 if available)
* - urgentIngredients[]: pantry items with urgency=urgent that this recipe would use
* - reasoning: human-readable explanation of why this recipe was suggested
*/
suggestRecipes(context: SuggestionContext): Promise<RecipeSuggestion[]>;
}
```
**Ingredient matching logic**:
- Match recipe ingredient's `productId` against pantry items with `status: sealed|opened`
- Check quantity: is there enough? (approximate — compare units, flag if unclear)
- Prefer items with higher freshness urgency
**Scoring weights** (configurable per household):
```typescript
const DEFAULT_WEIGHTS = {
ingredientCoverage: 40,
freshnessUrgency: 30,
nutritionBalance: 20,
variety: 10,
};
```
### 8.4 — Shopping Gap Analysis
- `GET /meal-plans/:id/shopping-gap`:
- For each recipe in the meal plan, list required ingredients
- Cross-reference with current pantry (available quantity vs needed quantity)
- Return:
```typescript
interface ShoppingGap {
coveredByPantry: ShoppingGapItem[]; // Already have enough
needToBuy: ShoppingGapItem[]; // Partially or fully missing
pantryItemsUsed: PantryItemUsage[]; // Which pantry items will be consumed
}
interface ShoppingGapItem {
productId: string;
productName: string;
totalNeeded: { quantity: number; unit: ServingUnit };
availableInPantry: { quantity: number; unit: ServingUnit };
shortfall: { quantity: number; unit: ServingUnit };
usedInRecipes: string[]; // Recipe names
}
```
- This output feeds directly into Phase 9's auto-generated shopping lists
### 8.5 — LLM Suggestion Placeholder
- `POST /meal-plans/suggest-with-llm`:
- Builds a context object: pantry summary, nutrition targets, dietary preferences
- Calls `ILlmProvider.suggestMealPlan(context)`
- With `NoOpLlmProvider`: returns `{ available: false }`
- When wired (Phase 10): returns a full week meal plan draft
### 8.6 — Web UI: Meal Planning
- `/meal-plans` page:
- **Weekly calendar grid**: 7 columns (MonSun) × 4 rows (Breakfast, Lunch, Dinner, Snack)
- Each cell: drop zone for recipes, shows meal name + calorie badge
- **Drag-and-drop**: drag recipes from suggestion panel or between cells
- **Daily nutrition summary row** at bottom: calories, protein, carbs, fat bars
- Color-coded vs user's nutrition targets (under = blue, on-target = green, over = red)
- **Week navigation**: previous/next week arrows
- **Suggestion panel** (sidebar or drawer):
- "Suggestions based on your pantry" — ranked list from suggestion engine
- Each suggestion shows: recipe name, match score, "Uses: [urgent items]", "Need to buy: [missing items]"
- Click to expand: full ingredient match breakdown
- "Add to plan" button → pick day + meal type
- **Shopping gap tab**: shows what's needed beyond pantry, "Generate shopping list" button (Phase 9 integration)
- `/nutrition-targets` settings:
- Daily macro targets form (calories, protein, carbs, fat)
- Preset templates: "Maintenance", "Weight loss", "Muscle gain", "Custom"
- Visual preview: donut chart of macro ratios
---
## Acceptance Criteria
- [ ] Can create a weekly meal plan and add meals to specific days/slots
- [ ] Daily nutrition totals are computed and displayed
- [ ] Suggestion engine returns ranked recipes based on pantry state
- [ ] Suggestions prioritize recipes using soon-to-expire pantry items
- [ ] Shopping gap analysis correctly identifies missing ingredients
- [ ] Drag-and-drop works in the weekly calendar UI
- [ ] Nutrition targets can be set per user
- [ ] Daily nutrition bars show progress vs targets
- [ ] Variety scoring penalizes recently used recipes
---
## Estimated Effort
Large. The suggestion engine scoring algorithm, shopping gap analysis, and calendar UI with drag-and-drop are all significant features.

View file

@ -0,0 +1,357 @@
# Phase 9 — Grocery & Price Tracking
**Goal**: Track food shopping across stores, compare prices over time, optimize where to buy. Auto-generate shopping lists from meal plans (Phase 8) or manually. Close the loop: when items are purchased, add them to the pantry (Phase 7). Reuses Store infrastructure from Phase 4.
**Depends on**: Phase 0, Phase 4 (stores), Phase 5 (products), Phase 7 (pantry), Phase 8 (meal planning)
---
## Deliverables
1. `Store`, `PriceRecord`, `ShoppingList` MongoDB schemas
2. Shopping list CRUD with real-time sync (WebSocket)
3. Auto-generate shopping lists from meal plan gaps
4. Price entry and history tracking
5. Price analytics: cheapest store per product, per shopping list, trends
6. Shopping → Pantry flow (checked items → add to pantry)
7. Web UI: shopping lists, price history charts, store comparison
---
## Data Model
### Store Schema
```typescript
// packages/shared/src/types/store.ts
export interface Store {
id: string;
householdId: string;
name: string;
address?: string;
location?: {
lat: number;
lng: number;
};
url?: string;
notes?: string;
tags: string[]; // e.g., 'organic', 'bulk', 'discount'
isActive: boolean;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
```
### PriceRecord Schema
```typescript
// packages/shared/src/types/price.ts
export interface PriceRecord {
id: string;
householdId: string;
productId: string;
productName: string; // Denormalized
storeId: string;
storeName: string; // Denormalized
price: number;
currency: string; // Default from household settings
quantity: number; // How much for this price
unit: ServingUnit;
pricePerUnit: number; // Computed: price / quantity (normalized)
date: Date;
receiptImageUrl?: string;
notes?: string;
createdBy: string;
createdAt: Date;
}
```
### ShoppingList Schema
```typescript
// packages/shared/src/types/shopping-list.ts
export interface ShoppingList {
id: string;
householdId: string;
name: string;
items: ShoppingItem[];
status: ShoppingListStatus;
createdFrom?: ShoppingListSource;
mealPlanId?: string;
totalEstimatedCost?: number; // Sum of estimated prices
preferredStoreId?: string;
completedAt?: Date;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
export interface ShoppingItem {
id: string; // UUID for real-time sync reference
productId?: string; // Linked product (optional for custom items)
customName?: string; // For items not in product library
quantity: number;
unit: ServingUnit;
checked: boolean;
checkedAt?: Date;
checkedBy?: string; // userId who checked it off
estimatedPrice?: number; // From price history
actualPrice?: number; // Entered when checked off
storeId?: string; // Preferred store for this item
notes?: string;
category?: ProductCategory; // For grouping in shopping aisle order
addedToPantry: boolean; // Tracks if item was added to pantry after purchase
}
export enum ShoppingListStatus {
ACTIVE = 'active',
SHOPPING = 'shopping', // Currently at the store
COMPLETED = 'completed',
ARCHIVED = 'archived',
}
export interface ShoppingListSource {
type: 'meal_plan' | 'manual' | 'pantry_restock';
referenceId?: string; // MealPlan ID, etc.
}
```
### MongoDB Indexes
```javascript
// PriceRecord
{ householdId: 1, productId: 1, storeId: 1, date: -1 } // Price history per product per store
{ householdId: 1, productId: 1, date: -1 } // Price history per product (all stores)
{ householdId: 1, storeId: 1, date: -1 } // All purchases at a store
{ date: 1, expireAfterSeconds: 63072000 } // Optional TTL: 2 years
// ShoppingList
{ householdId: 1, status: 1 }
{ householdId: 1, createdAt: -1 }
// Store
{ householdId: 1, name: 1 }
```
---
## API Endpoints
### StoresModule
| Method | Path | Description | Auth |
| ------ | ------------- | ------------------------- | ------ |
| GET | `/stores` | List stores for household | member |
| POST | `/stores` | Add a store | member |
| PATCH | `/stores/:id` | Update store | member |
| DELETE | `/stores/:id` | Deactivate store | admin |
### PriceRecordsModule
| Method | Path | Description | Auth |
| ------ | ---------------------------- | ------------------------------------- | ------ |
| POST | `/prices` | Record a price | member |
| POST | `/prices/bulk` | Record multiple prices (from receipt) | member |
| GET | `/prices/history/:productId` | Price history for a product | member |
| GET | `/prices/compare/:productId` | Compare stores for a product | member |
| GET | `/prices/analytics` | Aggregated price analytics | member |
| POST | `/prices/parse-receipt` | LLM receipt parsing (placeholder) | member |
### ShoppingListsModule
| Method | Path | Description | Auth |
| ------ | ---------------------------------------- | ---------------------------------------------- | ------ |
| GET | `/shopping-lists` | List shopping lists | member |
| GET | `/shopping-lists/:id` | Get shopping list | member |
| POST | `/shopping-lists` | Create shopping list | member |
| PATCH | `/shopping-lists/:id` | Update list metadata | member |
| DELETE | `/shopping-lists/:id` | Delete list | admin |
| POST | `/shopping-lists/:id/items` | Add item to list | member |
| PATCH | `/shopping-lists/:id/items/:itemId` | Update item (check off, change qty, set price) | member |
| DELETE | `/shopping-lists/:id/items/:itemId` | Remove item from list | member |
| POST | `/shopping-lists/from-meal-plan/:planId` | Auto-generate from meal plan gap analysis | member |
| POST | `/shopping-lists/:id/add-to-pantry` | Move checked items to pantry | member |
| GET | `/shopping-lists/:id/store-comparison` | Best store(s) for this list | member |
---
## Tasks
### 9.1 — Shared Types & Validation
- Add all types above to `packages/shared`
- Zod schemas for all create/update operations
### 9.2 — Stores CRUD
- Standard CRUD, straightforward
### 9.3 — Price Record Service
```typescript
class PriceService {
/** Record a price, computing pricePerUnit */
recordPrice(data: CreatePriceRecord): Promise<PriceRecord>;
/** Get price history for a product, optionally filtered by store */
getPriceHistory(
productId: string,
householdId: string,
options?: {
storeId?: string;
startDate?: Date;
endDate?: Date;
limit?: number;
},
): Promise<PriceRecord[]>;
/** Compare current prices across stores for a product */
compareStores(productId: string, householdId: string): Promise<StoreComparison[]>;
/** Estimate price for a product based on recent history */
estimatePrice(productId: string, householdId: string, storeId?: string): Promise<number | null>;
/** Detect significant price changes */
detectPriceChanges(householdId: string): Promise<PriceAlert[]>;
}
```
### 9.4 — Shopping List CRUD & Real-Time Sync
- Standard CRUD
- **WebSocket integration**: shopping list room per list ID
- Events: `shopping:item-checked`, `shopping:item-added`, `shopping:item-removed`, `shopping:item-updated`
- Enables multiple household members to shop simultaneously with real-time checkoff sync
- Optimistic updates on frontend with server reconciliation
### 9.5 — Auto-Generate from Meal Plan
- `POST /shopping-lists/from-meal-plan/:planId`:
1. Call Phase 8's shopping gap analysis for the meal plan
2. For each item in `needToBuy`:
- Create a `ShoppingItem` linked to the product
- Call `PriceService.estimatePrice()` to pre-fill estimated price
- Set `category` for store aisle grouping
3. Optionally group by cheapest store per item
4. Return the created shopping list
### 9.6 — Shopping → Pantry Flow
- `POST /shopping-lists/:id/add-to-pantry`:
- For each checked (purchased) item with `addedToPantry: false`:
- Create a `PantryItem` in Phase 7 (status: sealed, purchaseDate: today)
- If `actualPrice` was entered, create a `PriceRecord`
- Mark `addedToPantry: true`
- Return summary: `{ addedCount, priceRecordsCreated }`
### 9.7 — Price Analytics
- `GET /prices/analytics`:
```typescript
interface PriceAnalytics {
/** Average basket cost per store over the last N trips */
averageBasketByStore: {
storeId: string;
storeName: string;
avgTotal: number;
tripCount: number;
}[];
/** Products with significant price increases */
priceAlerts: PriceAlert[];
/** Total spending per period */
spendingOverTime: { period: string; total: number }[];
/** Most expensive categories */
spendingByCategory: { category: ProductCategory; total: number; avgPerItem: number }[];
}
interface PriceAlert {
productId: string;
productName: string;
storeId: string;
storeName: string;
previousPrice: number;
currentPrice: number;
changePercent: number;
date: Date;
}
```
### 9.8 — Store Comparison for Shopping List
- `GET /shopping-lists/:id/store-comparison`:
- For each item in the list, find the cheapest recent price per store
- Calculate total list cost per store
- Suggest: "Buy everything at Store A: $X" vs "Split between stores: $Y"
- Consider: is the savings worth going to multiple stores?
```typescript
interface StoreComparisonResult {
singleStoreOptions: {
storeId: string;
storeName: string;
estimatedTotal: number;
itemsCovered: number; // Not all stores carry all products
itemsMissing: string[];
}[];
splitStoreOption?: {
stores: { storeId: string; storeName: string; items: string[]; subtotal: number }[];
estimatedTotal: number;
savingsVsBestSingleStore: number;
};
}
```
### 9.9 — Receipt Parsing Placeholder
- `POST /prices/parse-receipt`:
- Accepts image upload
- Calls `ILlmProvider.parseReceipt(image)`
- Expected return: `{ storeName, date, items[]: { name, price, quantity } }`
- Match items to products (fuzzy), match store to stores
- With `NoOpLlmProvider`: returns `{ available: false }`
### 9.10 — Web UI: Grocery Management
- `/shopping-lists` page:
- Active lists at top, completed/archived below
- "New List" button (manual or from meal plan)
- Each list card: name, item count, estimated cost, completion %
- `/shopping-lists/:id` page (the "shopping mode"):
- Items grouped by category (aisle order)
- Each item: checkbox, name, quantity, estimated price
- Check off: expand to enter actual price (optional)
- Real-time sync indicator ("2 members shopping")
- "Done Shopping" button → prompts "Add items to pantry?"
- `/stores` page:
- Store list with CRUD
- Per-store: total spent, last visit, product count
- `/prices` page (analytics):
- Product search → price history line chart (per store, color-coded)
- Store comparison table
- Spending over time bar chart
- Price alerts panel
- **Shopping list widget on dashboard**: shows active lists with quick-check functionality
---
## Acceptance Criteria
- [ ] Can create shopping lists manually and from meal plans
- [ ] Shopping list items sync in real-time across household members via WebSocket
- [ ] Can record prices and view price history per product
- [ ] Store comparison recommends cheapest store for a shopping list
- [ ] Checked off items can be added to pantry with one action
- [ ] Price analytics show spending trends and alerts
- [ ] Auto-generated lists from meal plans correctly reflect shopping gap
- [ ] Receipt parsing endpoint delegates to LLM provider
---
## Estimated Effort
Large. Real-time shopping sync, price analytics aggregations, store comparison algorithm, and the shopping-to-pantry flow involve significant logic and UI.