This commit is contained in:
Aerilyn Weber 2026-05-14 18:57:57 +09:00
parent e396f5088c
commit a1801af63b
36 changed files with 4783 additions and 31 deletions

View file

@ -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';

View 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');
});
});

View 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',
}

View file

@ -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';

View 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;
}

View 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;
}

View file

@ -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';

View 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);
});
});
});

View 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>;

View 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);
});
});
});

View 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>;