Phase 9
This commit is contained in:
parent
e396f5088c
commit
a1801af63b
36 changed files with 4783 additions and 31 deletions
|
|
@ -9,3 +9,4 @@ export * from './product.enums.js';
|
|||
export * from './recipe.enums.js';
|
||||
export * from './pantry.enums.js';
|
||||
export * from './meal-plan.enums.js';
|
||||
export * from './shopping-list.enums.js';
|
||||
|
|
|
|||
17
packages/shared/src/enums/shopping-list.enums.test.ts
Normal file
17
packages/shared/src/enums/shopping-list.enums.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { ShoppingListStatus, ShoppingListSourceType } from './shopping-list.enums.js';
|
||||
|
||||
describe('ShoppingList Enums', () => {
|
||||
it('should have correct ShoppingListStatus values', () => {
|
||||
expect(ShoppingListStatus.ACTIVE).toBe('active');
|
||||
expect(ShoppingListStatus.SHOPPING).toBe('shopping');
|
||||
expect(ShoppingListStatus.COMPLETED).toBe('completed');
|
||||
expect(ShoppingListStatus.ARCHIVED).toBe('archived');
|
||||
});
|
||||
|
||||
it('should have correct ShoppingListSourceType values', () => {
|
||||
expect(ShoppingListSourceType.MEAL_PLAN).toBe('meal_plan');
|
||||
expect(ShoppingListSourceType.MANUAL).toBe('manual');
|
||||
expect(ShoppingListSourceType.PANTRY_RESTOCK).toBe('pantry_restock');
|
||||
});
|
||||
});
|
||||
18
packages/shared/src/enums/shopping-list.enums.ts
Normal file
18
packages/shared/src/enums/shopping-list.enums.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* State progression for grocery shopping lists
|
||||
*/
|
||||
export enum ShoppingListStatus {
|
||||
ACTIVE = 'active',
|
||||
SHOPPING = 'shopping',
|
||||
COMPLETED = 'completed',
|
||||
ARCHIVED = 'archived',
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies the genesis of a generated shopping list
|
||||
*/
|
||||
export enum ShoppingListSourceType {
|
||||
MEAL_PLAN = 'meal_plan',
|
||||
MANUAL = 'manual',
|
||||
PANTRY_RESTOCK = 'pantry_restock',
|
||||
}
|
||||
|
|
@ -17,3 +17,5 @@ export * from './pantry.js';
|
|||
export * from './meal-plan.js';
|
||||
export * from './nutrition-target.js';
|
||||
export * from './freshness.js';
|
||||
export * from './price-record.js';
|
||||
export * from './shopping-list.js';
|
||||
|
|
|
|||
23
packages/shared/src/types/price-record.ts
Normal file
23
packages/shared/src/types/price-record.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { ServingUnit } from '../enums/product.enums.js';
|
||||
|
||||
/**
|
||||
* Historical transaction capturing store product pricing
|
||||
*/
|
||||
export interface PriceRecord {
|
||||
id: string;
|
||||
householdId: string;
|
||||
productId: string;
|
||||
productName: string; // Denormalized for quick display
|
||||
storeId: string;
|
||||
storeName: string; // Denormalized for quick display
|
||||
price: number;
|
||||
currency: string;
|
||||
quantity: number;
|
||||
unit: ServingUnit;
|
||||
pricePerUnit: number; // price / quantity (computed/normalized value)
|
||||
date: Date;
|
||||
receiptImageUrl?: string;
|
||||
notes?: string;
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
49
packages/shared/src/types/shopping-list.ts
Normal file
49
packages/shared/src/types/shopping-list.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import type { ServingUnit, ProductCategory } from '../enums/product.enums.js';
|
||||
import type { ShoppingListStatus, ShoppingListSourceType } from '../enums/shopping-list.enums.js';
|
||||
|
||||
/**
|
||||
* Captures a targeted purchasable item within a shopping checklist
|
||||
*/
|
||||
export interface ShoppingItem {
|
||||
id: string; // Random reference token for live sync transactions
|
||||
productId?: string; // Reference to general inventory product
|
||||
customName?: string; // Arbitrary string label for custom ad-hoc additions
|
||||
quantity: number;
|
||||
unit: ServingUnit;
|
||||
checked: boolean;
|
||||
checkedAt?: Date;
|
||||
checkedBy?: string;
|
||||
estimatedPrice?: number; // Guessed via historical lookup
|
||||
actualPrice?: number; // Final value input by active shopper
|
||||
storeId?: string; // Local target retail outlet
|
||||
notes?: string;
|
||||
category?: ProductCategory; // Logical aisle group
|
||||
addedToPantry: boolean; // Tracking bit for pantry inventory migrations
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes generating origins for auto-lists
|
||||
*/
|
||||
export interface ShoppingListSource {
|
||||
type: ShoppingListSourceType;
|
||||
referenceId?: string; // ID pointing to generating instance (e.g. MealPlan ID)
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured grocery list managing household inventory provisioning
|
||||
*/
|
||||
export interface ShoppingList {
|
||||
id: string;
|
||||
householdId: string;
|
||||
name: string;
|
||||
items: ShoppingItem[];
|
||||
status: ShoppingListStatus;
|
||||
createdFrom?: ShoppingListSource;
|
||||
mealPlanId?: string; // Explicit quick reference pointer if derived from plan
|
||||
totalEstimatedCost?: number; // Summed pre-checkout estimates
|
||||
preferredStoreId?: string;
|
||||
completedAt?: Date;
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
|
@ -15,3 +15,5 @@ export * from './nutrition-target.schemas.js';
|
|||
export * from './recipe.schemas.js';
|
||||
export * from './pantry.schemas.js';
|
||||
export * from './freshness-rule.schemas.js';
|
||||
export * from './price-record.schemas.js';
|
||||
export * from './shopping-list.schemas.js';
|
||||
|
|
|
|||
56
packages/shared/src/validation/price-record.schemas.test.ts
Normal file
56
packages/shared/src/validation/price-record.schemas.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { CreatePriceRecordSchema, BulkPriceRecordInputSchema } from './price-record.schemas.js';
|
||||
import { ServingUnit } from '../enums/product.enums.js';
|
||||
|
||||
describe('PriceRecord Schemas', () => {
|
||||
describe('CreatePriceRecordSchema', () => {
|
||||
it('should validate a valid payload', () => {
|
||||
const payload = {
|
||||
productId: 'prod-1',
|
||||
storeId: 'store-1',
|
||||
price: 4.99,
|
||||
currency: 'USD',
|
||||
quantity: 500,
|
||||
unit: ServingUnit.GRAMS,
|
||||
notes: 'On sale',
|
||||
};
|
||||
const result = CreatePriceRecordSchema.safeParse(payload);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject negative prices', () => {
|
||||
const payload = {
|
||||
productId: 'prod-1',
|
||||
storeId: 'store-1',
|
||||
price: -1.5,
|
||||
quantity: 500,
|
||||
unit: ServingUnit.GRAMS,
|
||||
};
|
||||
const result = CreatePriceRecordSchema.safeParse(payload);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BulkPriceRecordInputSchema', () => {
|
||||
it('should validate valid bulk inputs', () => {
|
||||
const payload = {
|
||||
storeId: 'store-2',
|
||||
items: [
|
||||
{ productId: 'p1', price: 2.5, quantity: 1, unit: ServingUnit.PIECES },
|
||||
{ productId: 'p2', price: 3.0, quantity: 100, unit: ServingUnit.MILLILITERS },
|
||||
],
|
||||
};
|
||||
const result = BulkPriceRecordInputSchema.safeParse(payload);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should require at least one item', () => {
|
||||
const payload = {
|
||||
storeId: 'store-2',
|
||||
items: [],
|
||||
};
|
||||
const result = BulkPriceRecordInputSchema.safeParse(payload);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
118
packages/shared/src/validation/price-record.schemas.ts
Normal file
118
packages/shared/src/validation/price-record.schemas.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { z } from 'zod/v4';
|
||||
import { ServingUnit } from '../enums/product.enums.js';
|
||||
|
||||
export const CreatePriceRecordSchema = z.object({
|
||||
productId: z.string().min(1),
|
||||
storeId: z.string().min(1),
|
||||
price: z.number().positive(),
|
||||
currency: z.string().min(1).max(10).default('USD'),
|
||||
quantity: z.number().positive(),
|
||||
unit: z.nativeEnum(ServingUnit),
|
||||
date: z.iso.datetime().optional(),
|
||||
notes: z.string().max(1000).trim().optional(),
|
||||
receiptImageUrl: z.string().url().optional(),
|
||||
});
|
||||
|
||||
export const BulkPriceRecordInputSchema = z.object({
|
||||
storeId: z.string().min(1),
|
||||
date: z.iso.datetime().optional(),
|
||||
items: z.array(
|
||||
z.object({
|
||||
productId: z.string().min(1),
|
||||
price: z.number().positive(),
|
||||
quantity: z.number().positive(),
|
||||
unit: z.nativeEnum(ServingUnit),
|
||||
notes: z.string().max(1000).trim().optional(),
|
||||
})
|
||||
).min(1),
|
||||
});
|
||||
|
||||
export const PriceHistoryQuerySchema = z.object({
|
||||
storeId: z.string().optional(),
|
||||
startDate: z.iso.datetime().optional(),
|
||||
endDate: z.iso.datetime().optional(),
|
||||
cursor: z.string().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
export const PriceRecordResponseSchema = z.object({
|
||||
_id: z.string(),
|
||||
householdId: z.string(),
|
||||
productId: z.string(),
|
||||
productName: z.string(),
|
||||
storeId: z.string(),
|
||||
storeName: z.string(),
|
||||
price: z.number(),
|
||||
currency: z.string(),
|
||||
quantity: z.number(),
|
||||
unit: z.nativeEnum(ServingUnit),
|
||||
pricePerUnit: z.number(),
|
||||
date: z.string(),
|
||||
receiptImageUrl: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
createdBy: z.string(),
|
||||
createdAt: z.string(),
|
||||
});
|
||||
|
||||
export const StorePriceComparisonSchema = z.object({
|
||||
storeId: z.string(),
|
||||
storeName: z.string(),
|
||||
latestPrice: z.number(),
|
||||
latestPricePerUnit: z.number(),
|
||||
currency: z.string(),
|
||||
date: z.string(),
|
||||
});
|
||||
|
||||
export const PriceAnalyticsResponseSchema = z.object({
|
||||
averageBasketByStore: z.array(
|
||||
z.object({
|
||||
storeId: z.string(),
|
||||
storeName: z.string(),
|
||||
avgTotal: z.number(),
|
||||
tripCount: z.number(),
|
||||
})
|
||||
),
|
||||
priceAlerts: z.array(
|
||||
z.object({
|
||||
productId: z.string(),
|
||||
productName: z.string(),
|
||||
storeId: z.string(),
|
||||
storeName: z.string(),
|
||||
previousPrice: z.number(),
|
||||
currentPrice: z.number(),
|
||||
changePercent: z.number(),
|
||||
date: z.string(),
|
||||
})
|
||||
),
|
||||
spendingOverTime: z.array(
|
||||
z.object({
|
||||
period: z.string(),
|
||||
total: z.number(),
|
||||
})
|
||||
),
|
||||
spendingByCategory: z.array(
|
||||
z.object({
|
||||
category: z.string(),
|
||||
total: z.number(),
|
||||
avgPerItem: z.number(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export const PriceHistoryResponseSchema = z.object({
|
||||
data: z.array(PriceRecordResponseSchema),
|
||||
pagination: z.object({
|
||||
cursor: z.string().nullable(),
|
||||
hasMore: z.boolean(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const FoodStoreComparisonResponseSchema = z.object({
|
||||
data: z.array(StorePriceComparisonSchema),
|
||||
});
|
||||
|
||||
export const FoodSpendingAnalyticsResponseSchema = PriceAnalyticsResponseSchema;
|
||||
|
||||
export type CreatePriceRecordInput = z.infer<typeof CreatePriceRecordSchema>;
|
||||
export type BulkPriceRecordInput = z.infer<typeof BulkPriceRecordInputSchema>;
|
||||
export type PriceHistoryQueryInput = z.infer<typeof PriceHistoryQuerySchema>;
|
||||
66
packages/shared/src/validation/shopping-list.schemas.test.ts
Normal file
66
packages/shared/src/validation/shopping-list.schemas.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
CreateShoppingListSchema,
|
||||
AddShoppingItemSchema,
|
||||
UpdateShoppingItemSchema,
|
||||
} from './shopping-list.schemas.js';
|
||||
import { ServingUnit } from '../enums/product.enums.js';
|
||||
|
||||
describe('ShoppingList Schemas', () => {
|
||||
describe('CreateShoppingListSchema', () => {
|
||||
it('should accept lists with items', () => {
|
||||
const payload = {
|
||||
name: 'Weekly run',
|
||||
items: [
|
||||
{ productId: 'p1', quantity: 2, unit: ServingUnit.PIECES },
|
||||
{ customName: 'Apples', quantity: 10, unit: ServingUnit.PIECES },
|
||||
],
|
||||
};
|
||||
const result = CreateShoppingListSchema.safeParse(payload);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should default to empty items array', () => {
|
||||
const payload = { name: 'Mini list' };
|
||||
const result = CreateShoppingListSchema.safeParse(payload);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.items).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('AddShoppingItemSchema', () => {
|
||||
it('should pass with a productId', () => {
|
||||
const payload = { productId: 'prod-123', quantity: 1, unit: ServingUnit.MILLILITERS };
|
||||
const result = AddShoppingItemSchema.safeParse(payload);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should pass with a customName', () => {
|
||||
const payload = { customName: 'Fresh fish', quantity: 1.5, unit: ServingUnit.GRAMS };
|
||||
const result = AddShoppingItemSchema.safeParse(payload);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should fail if both are omitted', () => {
|
||||
const payload = { quantity: 5, unit: ServingUnit.PIECES };
|
||||
const result = AddShoppingItemSchema.safeParse(payload);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateShoppingItemSchema', () => {
|
||||
it('should accept partial updates', () => {
|
||||
const payload = { checked: true, actualPrice: 2.99 };
|
||||
const result = UpdateShoppingItemSchema.safeParse(payload);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject negative prices on checkoff', () => {
|
||||
const payload = { actualPrice: -0.5 };
|
||||
const result = UpdateShoppingItemSchema.safeParse(payload);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
122
packages/shared/src/validation/shopping-list.schemas.ts
Normal file
122
packages/shared/src/validation/shopping-list.schemas.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { z } from 'zod/v4';
|
||||
import { ServingUnit, ProductCategory } from '../enums/product.enums.js';
|
||||
import { ShoppingListStatus, ShoppingListSourceType } from '../enums/shopping-list.enums.js';
|
||||
|
||||
export const ShoppingItemSchema = z.object({
|
||||
id: z.string(),
|
||||
productId: z.string().optional(),
|
||||
customName: z.string().optional(),
|
||||
quantity: z.number().positive(),
|
||||
unit: z.nativeEnum(ServingUnit),
|
||||
checked: z.boolean().default(false),
|
||||
checkedAt: z.iso.datetime().optional(),
|
||||
checkedBy: z.string().optional(),
|
||||
estimatedPrice: z.number().optional(),
|
||||
actualPrice: z.number().optional(),
|
||||
storeId: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
category: z.nativeEnum(ProductCategory).optional(),
|
||||
addedToPantry: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const CreateShoppingListSchema = z.object({
|
||||
name: z.string().min(1).max(100).trim(),
|
||||
preferredStoreId: z.string().optional(),
|
||||
items: z.array(
|
||||
z.object({
|
||||
productId: z.string().optional(),
|
||||
customName: z.string().optional(),
|
||||
quantity: z.number().positive(),
|
||||
unit: z.nativeEnum(ServingUnit),
|
||||
notes: z.string().optional(),
|
||||
category: z.nativeEnum(ProductCategory).optional(),
|
||||
})
|
||||
).optional().default([]),
|
||||
});
|
||||
|
||||
export const UpdateShoppingListSchema = z.object({
|
||||
name: z.string().min(1).max(100).trim().optional(),
|
||||
status: z.nativeEnum(ShoppingListStatus).optional(),
|
||||
preferredStoreId: z.string().optional(),
|
||||
items: z.array(ShoppingItemSchema).optional(),
|
||||
});
|
||||
|
||||
export const AddShoppingItemSchema = z.object({
|
||||
productId: z.string().optional(),
|
||||
customName: z.string().optional(),
|
||||
quantity: z.number().positive(),
|
||||
unit: z.nativeEnum(ServingUnit),
|
||||
notes: z.string().max(500).trim().optional(),
|
||||
category: z.nativeEnum(ProductCategory).optional(),
|
||||
}).refine(
|
||||
(data) => data.productId || data.customName,
|
||||
{ message: 'Must provide either a productId or customName' }
|
||||
);
|
||||
|
||||
export const UpdateShoppingItemSchema = z.object({
|
||||
quantity: z.number().positive().optional(),
|
||||
unit: z.nativeEnum(ServingUnit).optional(),
|
||||
checked: z.boolean().optional(),
|
||||
actualPrice: z.number().nonnegative().optional(),
|
||||
storeId: z.string().optional(),
|
||||
notes: z.string().max(500).trim().optional(),
|
||||
});
|
||||
|
||||
export const ShoppingListResponseSchema = z.object({
|
||||
_id: z.string(),
|
||||
householdId: z.string(),
|
||||
name: z.string(),
|
||||
status: z.nativeEnum(ShoppingListStatus),
|
||||
items: z.array(ShoppingItemSchema),
|
||||
createdFrom: z.object({
|
||||
type: z.nativeEnum(ShoppingListSourceType),
|
||||
referenceId: z.string().optional(),
|
||||
}).optional(),
|
||||
mealPlanId: z.string().optional(),
|
||||
totalEstimatedCost: z.number().optional(),
|
||||
preferredStoreId: z.string().optional(),
|
||||
completedAt: z.string().optional(),
|
||||
createdBy: z.string(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
export const ShoppingListListResponseSchema = z.object({
|
||||
data: z.array(ShoppingListResponseSchema),
|
||||
});
|
||||
|
||||
export const StoreComparisonResultSchema = z.object({
|
||||
singleStoreOptions: z.array(
|
||||
z.object({
|
||||
storeId: z.string(),
|
||||
storeName: z.string(),
|
||||
estimatedTotal: z.number(),
|
||||
itemsCovered: z.number(),
|
||||
itemsMissing: z.array(z.string()),
|
||||
})
|
||||
),
|
||||
splitStoreOption: z.object({
|
||||
stores: z.array(
|
||||
z.object({
|
||||
storeId: z.string(),
|
||||
storeName: z.string(),
|
||||
items: z.array(z.string()),
|
||||
subtotal: z.number(),
|
||||
})
|
||||
),
|
||||
estimatedTotal: z.number(),
|
||||
savingsVsBestSingleStore: z.number(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export const BasketStoreComparisonResponseSchema = StoreComparisonResultSchema;
|
||||
|
||||
export const ShoppingListSyncToPantryResponseSchema = z.object({
|
||||
addedCount: z.number(),
|
||||
pricesLogged: z.number(),
|
||||
});
|
||||
|
||||
export type CreateShoppingListInput = z.infer<typeof CreateShoppingListSchema>;
|
||||
export type UpdateShoppingListInput = z.infer<typeof UpdateShoppingListSchema>;
|
||||
export type AddShoppingItemInput = z.infer<typeof AddShoppingItemSchema>;
|
||||
export type UpdateShoppingItemInput = z.infer<typeof UpdateShoppingItemSchema>;
|
||||
Loading…
Add table
Add a link
Reference in a new issue