347 lines
12 KiB
Markdown
347 lines
12 KiB
Markdown
|
|
# 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.
|