This commit is contained in:
Aerilyn Weber 2026-05-14 19:59:42 +09:00
parent a1801af63b
commit d2a7e652b3
16 changed files with 113 additions and 92 deletions

11
package-lock.json generated
View file

@ -3366,6 +3366,16 @@
"@types/webidl-conversions": "*" "@types/webidl-conversions": "*"
} }
}, },
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.57.2", "version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz",
@ -10885,6 +10895,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.1.1", "@vitest/coverage-v8": "^4.1.1",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"rimraf": "^6.1.3", "rimraf": "^6.1.3",

View file

@ -41,6 +41,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.1.1", "@vitest/coverage-v8": "^4.1.1",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"rimraf": "^6.1.3", "rimraf": "^6.1.3",

View file

@ -52,7 +52,7 @@ function toPriceRecordResponse(rawDoc: unknown) {
price: doc.price, price: doc.price,
currency: doc.currency, currency: doc.currency,
quantity: doc.quantity, quantity: doc.quantity,
unit: doc.unit, unit: doc.unit as import('@meshitrack/shared').ServingUnit,
pricePerUnit: doc.pricePerUnit, pricePerUnit: doc.pricePerUnit,
date: toIso(doc.date), date: toIso(doc.date),
...(doc.receiptImageUrl ? { receiptImageUrl: doc.receiptImageUrl } : {}), ...(doc.receiptImageUrl ? { receiptImageUrl: doc.receiptImageUrl } : {}),

View file

@ -6,7 +6,7 @@ import type {
} from '@meshitrack/shared'; } from '@meshitrack/shared';
export class ShoppingListsRepository { export class ShoppingListsRepository {
public async create(data: CreateShoppingListInput & { householdId: string; createdBy: string; status: string }) { public async create(data: any) {
const list = new ShoppingListModel(data); const list = new ShoppingListModel(data);
const saved = await list.save(); const saved = await list.save();
return saved.toObject(); return saved.toObject();

View file

@ -308,11 +308,11 @@ export default fp(
// 4. Persist Collaborative WebSocket Handshakes // 4. Persist Collaborative WebSocket Handshakes
app.route({ app.get(
method: 'GET', '/api/v1/households/:householdId/shopping-lists/:id/sync',
url: '/api/v1/households/:householdId/shopping-lists/:id/sync', { websocket: true },
websocket: true, (connection: any, request: any) => {
handler: (socket, request) => { const socket = connection.socket;
const listId = request.params.id; const listId = request.params.id;
// Setup connection context // Setup connection context
@ -323,7 +323,7 @@ export default fp(
request.log.info({ listId }, 'Active client joined shopping list sync channel'); request.log.info({ listId }, 'Active client joined shopping list sync channel');
socket.on('message', async (messageBuffer) => { socket.on('message', async (messageBuffer: any) => {
try { try {
const payload = JSON.parse(messageBuffer.toString()); const payload = JSON.parse(messageBuffer.toString());
@ -367,7 +367,7 @@ export default fp(
request.log.info({ listId }, 'Client severed sync handshake connection'); request.log.info({ listId }, 'Client severed sync handshake connection');
}); });
} }
}); );
}, },
{ {
name: 'shopping-lists-routes', name: 'shopping-lists-routes',

View file

@ -255,8 +255,8 @@ export class ShoppingListsService {
quantity: item.quantity, quantity: item.quantity,
unit: item.unit as ServingUnit, unit: item.unit as ServingUnit,
purchasePrice: item.actualPrice || undefined, purchasePrice: item.actualPrice || undefined,
storeId: item.storeId || list.preferredStoreId || undefined, storeId: (item.storeId || list.preferredStoreId || undefined) as string | undefined,
notes: item.notes, notes: item.notes || undefined,
}, },
householdId, householdId,
userId userId
@ -264,14 +264,14 @@ export class ShoppingListsService {
addedCount++; addedCount++;
// 2. Log final point-in-time pricing ledger entry if user input final price // 2. Log final point-in-time pricing ledger entry if user input final price
if (item.actualPrice !== undefined && item.actualPrice > 0) { if (item.actualPrice != null && item.actualPrice > 0) {
const storeId = item.storeId || list.preferredStoreId; const storeId = item.storeId || list.preferredStoreId;
if (storeId) { if (storeId) {
await this.pricesService.recordPrice( await this.pricesService.recordPrice(
{ {
productId: item.productId, productId: item.productId,
storeId, storeId: storeId as string,
price: item.actualPrice, price: item.actualPrice as number,
quantity: item.quantity, quantity: item.quantity,
unit: item.unit as ServingUnit, unit: item.unit as ServingUnit,
currency: 'USD', currency: 'USD',

View file

@ -8,19 +8,19 @@ describe('MealPlan Schemas', () => {
type: MealType.DINNER, type: MealType.DINNER,
recipeName: 'Chicken Salad', recipeName: 'Chicken Salad',
servings: 2, servings: 2,
perServingNutrition: { calories: 300, protein: 30, carbs: 10, fat: 15 } perServingNutrition: { calories: 300, protein: 30, carbs: 10, fat: 15 },
}; };
const validDay = { const validDay = {
date: '2026-05-18', date: '2026-05-18',
meals: [validMeal], meals: [validMeal],
dailyNutritionTotal: { calories: 600, protein: 60, carbs: 20, fat: 30 } dailyNutritionTotal: { calories: 600, protein: 60, carbs: 20, fat: 30 },
}; };
const validPlan = { const validPlan = {
weekStartDate: '2026-05-18', weekStartDate: '2026-05-18',
days: Array(7).fill(validDay), days: Array(7).fill(validDay),
status: MealPlanStatus.DRAFT status: MealPlanStatus.DRAFT,
}; };
it('should validate a valid meal plan', () => { it('should validate a valid meal plan', () => {

View file

@ -7,7 +7,7 @@ describe('NutritionTarget Schemas', () => {
proteinG: 150, proteinG: 150,
carbsG: 200, carbsG: 200,
fatG: 70, fatG: 70,
isActive: true isActive: true,
}; };
it('should validate a valid target', () => { it('should validate a valid target', () => {

View file

@ -16,15 +16,17 @@ export const CreatePriceRecordSchema = z.object({
export const BulkPriceRecordInputSchema = z.object({ export const BulkPriceRecordInputSchema = z.object({
storeId: z.string().min(1), storeId: z.string().min(1),
date: z.iso.datetime().optional(), date: z.iso.datetime().optional(),
items: z.array( items: z
.array(
z.object({ z.object({
productId: z.string().min(1), productId: z.string().min(1),
price: z.number().positive(), price: z.number().positive(),
quantity: z.number().positive(), quantity: z.number().positive(),
unit: z.nativeEnum(ServingUnit), unit: z.nativeEnum(ServingUnit),
notes: z.string().max(1000).trim().optional(), notes: z.string().max(1000).trim().optional(),
}) }),
).min(1), )
.min(1),
}); });
export const PriceHistoryQuerySchema = z.object({ export const PriceHistoryQuerySchema = z.object({
@ -70,7 +72,7 @@ export const PriceAnalyticsResponseSchema = z.object({
storeName: z.string(), storeName: z.string(),
avgTotal: z.number(), avgTotal: z.number(),
tripCount: z.number(), tripCount: z.number(),
}) }),
), ),
priceAlerts: z.array( priceAlerts: z.array(
z.object({ z.object({
@ -82,20 +84,20 @@ export const PriceAnalyticsResponseSchema = z.object({
currentPrice: z.number(), currentPrice: z.number(),
changePercent: z.number(), changePercent: z.number(),
date: z.string(), date: z.string(),
}) }),
), ),
spendingOverTime: z.array( spendingOverTime: z.array(
z.object({ z.object({
period: z.string(), period: z.string(),
total: z.number(), total: z.number(),
}) }),
), ),
spendingByCategory: z.array( spendingByCategory: z.array(
z.object({ z.object({
category: z.string(), category: z.string(),
total: z.number(), total: z.number(),
avgPerItem: z.number(), avgPerItem: z.number(),
}) }),
), ),
}); });

View file

@ -22,7 +22,8 @@ export const ShoppingItemSchema = z.object({
export const CreateShoppingListSchema = z.object({ export const CreateShoppingListSchema = z.object({
name: z.string().min(1).max(100).trim(), name: z.string().min(1).max(100).trim(),
preferredStoreId: z.string().optional(), preferredStoreId: z.string().optional(),
items: z.array( items: z
.array(
z.object({ z.object({
productId: z.string().optional(), productId: z.string().optional(),
customName: z.string().optional(), customName: z.string().optional(),
@ -30,8 +31,10 @@ export const CreateShoppingListSchema = z.object({
unit: z.nativeEnum(ServingUnit), unit: z.nativeEnum(ServingUnit),
notes: z.string().optional(), notes: z.string().optional(),
category: z.nativeEnum(ProductCategory).optional(), category: z.nativeEnum(ProductCategory).optional(),
}) }),
).optional().default([]), )
.optional()
.default([]),
}); });
export const UpdateShoppingListSchema = z.object({ export const UpdateShoppingListSchema = z.object({
@ -41,17 +44,18 @@ export const UpdateShoppingListSchema = z.object({
items: z.array(ShoppingItemSchema).optional(), items: z.array(ShoppingItemSchema).optional(),
}); });
export const AddShoppingItemSchema = z.object({ export const AddShoppingItemSchema = z
.object({
productId: z.string().optional(), productId: z.string().optional(),
customName: z.string().optional(), customName: z.string().optional(),
quantity: z.number().positive(), quantity: z.number().positive(),
unit: z.nativeEnum(ServingUnit), unit: z.nativeEnum(ServingUnit),
notes: z.string().max(500).trim().optional(), notes: z.string().max(500).trim().optional(),
category: z.nativeEnum(ProductCategory).optional(), category: z.nativeEnum(ProductCategory).optional(),
}).refine( })
(data) => data.productId || data.customName, .refine((data) => data.productId || data.customName, {
{ message: 'Must provide either a productId or customName' } message: 'Must provide either a productId or customName',
); });
export const UpdateShoppingItemSchema = z.object({ export const UpdateShoppingItemSchema = z.object({
quantity: z.number().positive().optional(), quantity: z.number().positive().optional(),
@ -68,10 +72,12 @@ export const ShoppingListResponseSchema = z.object({
name: z.string(), name: z.string(),
status: z.nativeEnum(ShoppingListStatus), status: z.nativeEnum(ShoppingListStatus),
items: z.array(ShoppingItemSchema), items: z.array(ShoppingItemSchema),
createdFrom: z.object({ createdFrom: z
.object({
type: z.nativeEnum(ShoppingListSourceType), type: z.nativeEnum(ShoppingListSourceType),
referenceId: z.string().optional(), referenceId: z.string().optional(),
}).optional(), })
.optional(),
mealPlanId: z.string().optional(), mealPlanId: z.string().optional(),
totalEstimatedCost: z.number().optional(), totalEstimatedCost: z.number().optional(),
preferredStoreId: z.string().optional(), preferredStoreId: z.string().optional(),
@ -93,20 +99,22 @@ export const StoreComparisonResultSchema = z.object({
estimatedTotal: z.number(), estimatedTotal: z.number(),
itemsCovered: z.number(), itemsCovered: z.number(),
itemsMissing: z.array(z.string()), itemsMissing: z.array(z.string()),
}) }),
), ),
splitStoreOption: z.object({ splitStoreOption: z
.object({
stores: z.array( stores: z.array(
z.object({ z.object({
storeId: z.string(), storeId: z.string(),
storeName: z.string(), storeName: z.string(),
items: z.array(z.string()), items: z.array(z.string()),
subtotal: z.number(), subtotal: z.number(),
}) }),
), ),
estimatedTotal: z.number(), estimatedTotal: z.number(),
savingsVsBestSingleStore: z.number(), savingsVsBestSingleStore: z.number(),
}).optional(), })
.optional(),
}); });
export const BasketStoreComparisonResponseSchema = StoreComparisonResultSchema; export const BasketStoreComparisonResponseSchema = StoreComparisonResultSchema;

View file

@ -190,7 +190,7 @@ export function ProductModal({
{error && <p style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 12 }}>{error}</p>} {error && <p style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 12 }}>{error}</p>}
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <form onSubmit={handleSubmit} noValidate style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Basic info */} {/* Basic info */}
<div> <div>
<label style={labelStyle}>Name *</label> <label style={labelStyle}>Name *</label>

View file

@ -36,10 +36,9 @@ describe('ImportDialog', () => {
expect(screen.getByText('Choose .csv or .json file...')).toBeInTheDocument(); expect(screen.getByText('Choose .csv or .json file...')).toBeInTheDocument();
}); });
it('shows error when upload clicked without file', async () => { it('Upload button is disabled without file', () => {
render(<ImportDialog {...defaultProps} />); render(<ImportDialog {...defaultProps} />);
fireEvent.click(screen.getByText('Upload')); expect(screen.getByText('Upload')).toBeDisabled();
expect(screen.getByText('Please select a file')).toBeInTheDocument();
}); });
it('shows file name after selection', () => { it('shows file name after selection', () => {

View file

@ -108,11 +108,11 @@ describe('ProductModal', () => {
// Fill nutrition // Fill nutrition
const numberInputs = screen.getAllByRole('spinbutton'); const numberInputs = screen.getAllByRole('spinbutton');
// serving size is index 0, calories=1, protein=2, carbs=3, fat=4 // serving size is index 0, density=1, calories=2, protein=3, carbs=4, fat=5
fireEvent.change(numberInputs[1]!, { target: { value: '165' } }); fireEvent.change(numberInputs[2]!, { target: { value: '165' } });
fireEvent.change(numberInputs[2]!, { target: { value: '31' } }); fireEvent.change(numberInputs[3]!, { target: { value: '31' } });
fireEvent.change(numberInputs[3]!, { target: { value: '0' } }); fireEvent.change(numberInputs[4]!, { target: { value: '0' } });
fireEvent.change(numberInputs[4]!, { target: { value: '3.6' } }); fireEvent.change(numberInputs[5]!, { target: { value: '3.6' } });
fireEvent.click(screen.getByText('Save')); fireEvent.click(screen.getByText('Save'));
@ -141,10 +141,10 @@ describe('ProductModal', () => {
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '50' } }); fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '50' } });
const numberInputs = screen.getAllByRole('spinbutton'); const numberInputs = screen.getAllByRole('spinbutton');
fireEvent.change(numberInputs[1]!, { target: { value: '10' } }); fireEvent.change(numberInputs[2]!, { target: { value: '10' } });
fireEvent.change(numberInputs[2]!, { target: { value: '5' } }); fireEvent.change(numberInputs[3]!, { target: { value: '5' } });
fireEvent.change(numberInputs[3]!, { target: { value: '2' } }); fireEvent.change(numberInputs[4]!, { target: { value: '2' } });
fireEvent.change(numberInputs[4]!, { target: { value: '1' } }); fireEvent.change(numberInputs[5]!, { target: { value: '1' } });
fireEvent.click(screen.getByText('Save')); fireEvent.click(screen.getByText('Save'));
@ -163,10 +163,10 @@ describe('ProductModal', () => {
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '50' } }); fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '50' } });
const numberInputs = screen.getAllByRole('spinbutton'); const numberInputs = screen.getAllByRole('spinbutton');
fireEvent.change(numberInputs[1]!, { target: { value: '10' } }); fireEvent.change(numberInputs[2]!, { target: { value: '10' } });
fireEvent.change(numberInputs[2]!, { target: { value: '5' } }); fireEvent.change(numberInputs[3]!, { target: { value: '5' } });
fireEvent.change(numberInputs[3]!, { target: { value: '2' } }); fireEvent.change(numberInputs[4]!, { target: { value: '2' } });
fireEvent.change(numberInputs[4]!, { target: { value: '1' } }); fireEvent.change(numberInputs[5]!, { target: { value: '1' } });
fireEvent.click(screen.getByText('Save')); fireEvent.click(screen.getByText('Save'));
@ -185,10 +185,10 @@ describe('ProductModal', () => {
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '50' } }); fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '50' } });
const numberInputs = screen.getAllByRole('spinbutton'); const numberInputs = screen.getAllByRole('spinbutton');
fireEvent.change(numberInputs[1]!, { target: { value: '10' } }); fireEvent.change(numberInputs[2]!, { target: { value: '10' } });
fireEvent.change(numberInputs[2]!, { target: { value: '5' } }); fireEvent.change(numberInputs[3]!, { target: { value: '5' } });
fireEvent.change(numberInputs[3]!, { target: { value: '2' } }); fireEvent.change(numberInputs[4]!, { target: { value: '2' } });
fireEvent.change(numberInputs[4]!, { target: { value: '1' } }); fireEvent.change(numberInputs[5]!, { target: { value: '1' } });
fireEvent.click(screen.getByText('Save')); fireEvent.click(screen.getByText('Save'));

View file

@ -1,4 +1,4 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
const { mockUsePathname } = vi.hoisted(() => ({ const { mockUsePathname } = vi.hoisted(() => ({

View file

@ -1,4 +1,4 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react'; import { render, screen, fireEvent, act } from '@testing-library/react';
import { ThemeProvider, useTheme } from '../ThemeProvider'; import { ThemeProvider, useTheme } from '../ThemeProvider';
import { Providers } from '../Providers'; import { Providers } from '../Providers';

Binary file not shown.