From d2a7e652b3d534dd28bc6fc648aa0246e2253838 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aerilyn=20Dziedzel=C4=97?= Date: Thu, 14 May 2026 19:59:42 +0900 Subject: [PATCH] Phase 9 --- package-lock.json | 11 +++ packages/api/package.json | 1 + .../api/src/modules/prices/prices.routes.ts | 2 +- .../shopping-lists.repository.ts | 2 +- .../shopping-lists/shopping-lists.routes.ts | 14 +-- .../shopping-lists/shopping-lists.service.ts | 10 +-- .../src/validation/meal-plan.schemas.test.ts | 6 +- .../nutrition-target.schemas.test.ts | 2 +- .../src/validation/price-record.schemas.ts | 28 +++--- .../src/validation/shopping-list.schemas.ts | 84 ++++++++++-------- .../app/(dashboard)/products/ProductModal.tsx | 2 +- .../products/__tests__/ImportDialog.test.tsx | 5 +- .../products/__tests__/ProductModal.test.tsx | 34 +++---- .../src/components/__tests__/Sidebar.test.tsx | 2 +- .../__tests__/ThemeProvider.test.tsx | 2 +- packages/web/test-output.txt | Bin 0 -> 35286 bytes 16 files changed, 113 insertions(+), 92 deletions(-) create mode 100644 packages/web/test-output.txt diff --git a/package-lock.json b/package-lock.json index 0e90217..96aba50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3366,6 +3366,16 @@ "@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": { "version": "8.57.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", @@ -10885,6 +10895,7 @@ }, "devDependencies": { "@types/node": "^25.5.0", + "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.1.1", "pino-pretty": "^13.1.3", "rimraf": "^6.1.3", diff --git a/packages/api/package.json b/packages/api/package.json index 610ffc5..c02ad9a 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -41,6 +41,7 @@ }, "devDependencies": { "@types/node": "^25.5.0", + "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.1.1", "pino-pretty": "^13.1.3", "rimraf": "^6.1.3", diff --git a/packages/api/src/modules/prices/prices.routes.ts b/packages/api/src/modules/prices/prices.routes.ts index cbf8257..84026bd 100644 --- a/packages/api/src/modules/prices/prices.routes.ts +++ b/packages/api/src/modules/prices/prices.routes.ts @@ -52,7 +52,7 @@ function toPriceRecordResponse(rawDoc: unknown) { price: doc.price, currency: doc.currency, quantity: doc.quantity, - unit: doc.unit, + unit: doc.unit as import('@meshitrack/shared').ServingUnit, pricePerUnit: doc.pricePerUnit, date: toIso(doc.date), ...(doc.receiptImageUrl ? { receiptImageUrl: doc.receiptImageUrl } : {}), diff --git a/packages/api/src/modules/shopping-lists/shopping-lists.repository.ts b/packages/api/src/modules/shopping-lists/shopping-lists.repository.ts index b2abf7d..7fa5be0 100644 --- a/packages/api/src/modules/shopping-lists/shopping-lists.repository.ts +++ b/packages/api/src/modules/shopping-lists/shopping-lists.repository.ts @@ -6,7 +6,7 @@ import type { } from '@meshitrack/shared'; 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 saved = await list.save(); return saved.toObject(); diff --git a/packages/api/src/modules/shopping-lists/shopping-lists.routes.ts b/packages/api/src/modules/shopping-lists/shopping-lists.routes.ts index 8117035..373759b 100644 --- a/packages/api/src/modules/shopping-lists/shopping-lists.routes.ts +++ b/packages/api/src/modules/shopping-lists/shopping-lists.routes.ts @@ -308,11 +308,11 @@ export default fp( // 4. Persist Collaborative WebSocket Handshakes - app.route({ - method: 'GET', - url: '/api/v1/households/:householdId/shopping-lists/:id/sync', - websocket: true, - handler: (socket, request) => { + app.get( + '/api/v1/households/:householdId/shopping-lists/:id/sync', + { websocket: true }, + (connection: any, request: any) => { + const socket = connection.socket; const listId = request.params.id; // Setup connection context @@ -323,7 +323,7 @@ export default fp( request.log.info({ listId }, 'Active client joined shopping list sync channel'); - socket.on('message', async (messageBuffer) => { + socket.on('message', async (messageBuffer: any) => { try { const payload = JSON.parse(messageBuffer.toString()); @@ -367,7 +367,7 @@ export default fp( request.log.info({ listId }, 'Client severed sync handshake connection'); }); } - }); + ); }, { name: 'shopping-lists-routes', diff --git a/packages/api/src/modules/shopping-lists/shopping-lists.service.ts b/packages/api/src/modules/shopping-lists/shopping-lists.service.ts index a817813..c279eb5 100644 --- a/packages/api/src/modules/shopping-lists/shopping-lists.service.ts +++ b/packages/api/src/modules/shopping-lists/shopping-lists.service.ts @@ -255,8 +255,8 @@ export class ShoppingListsService { quantity: item.quantity, unit: item.unit as ServingUnit, purchasePrice: item.actualPrice || undefined, - storeId: item.storeId || list.preferredStoreId || undefined, - notes: item.notes, + storeId: (item.storeId || list.preferredStoreId || undefined) as string | undefined, + notes: item.notes || undefined, }, householdId, userId @@ -264,14 +264,14 @@ export class ShoppingListsService { addedCount++; // 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; if (storeId) { await this.pricesService.recordPrice( { productId: item.productId, - storeId, - price: item.actualPrice, + storeId: storeId as string, + price: item.actualPrice as number, quantity: item.quantity, unit: item.unit as ServingUnit, currency: 'USD', diff --git a/packages/shared/src/validation/meal-plan.schemas.test.ts b/packages/shared/src/validation/meal-plan.schemas.test.ts index de38875..05bbb77 100644 --- a/packages/shared/src/validation/meal-plan.schemas.test.ts +++ b/packages/shared/src/validation/meal-plan.schemas.test.ts @@ -8,19 +8,19 @@ describe('MealPlan Schemas', () => { type: MealType.DINNER, recipeName: 'Chicken Salad', servings: 2, - perServingNutrition: { calories: 300, protein: 30, carbs: 10, fat: 15 } + perServingNutrition: { calories: 300, protein: 30, carbs: 10, fat: 15 }, }; const validDay = { date: '2026-05-18', meals: [validMeal], - dailyNutritionTotal: { calories: 600, protein: 60, carbs: 20, fat: 30 } + dailyNutritionTotal: { calories: 600, protein: 60, carbs: 20, fat: 30 }, }; const validPlan = { weekStartDate: '2026-05-18', days: Array(7).fill(validDay), - status: MealPlanStatus.DRAFT + status: MealPlanStatus.DRAFT, }; it('should validate a valid meal plan', () => { diff --git a/packages/shared/src/validation/nutrition-target.schemas.test.ts b/packages/shared/src/validation/nutrition-target.schemas.test.ts index fef5b53..8bb44bd 100644 --- a/packages/shared/src/validation/nutrition-target.schemas.test.ts +++ b/packages/shared/src/validation/nutrition-target.schemas.test.ts @@ -7,7 +7,7 @@ describe('NutritionTarget Schemas', () => { proteinG: 150, carbsG: 200, fatG: 70, - isActive: true + isActive: true, }; it('should validate a valid target', () => { diff --git a/packages/shared/src/validation/price-record.schemas.ts b/packages/shared/src/validation/price-record.schemas.ts index 24ae824..dfe1956 100644 --- a/packages/shared/src/validation/price-record.schemas.ts +++ b/packages/shared/src/validation/price-record.schemas.ts @@ -16,15 +16,17 @@ export const CreatePriceRecordSchema = z.object({ 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), + 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({ @@ -70,7 +72,7 @@ export const PriceAnalyticsResponseSchema = z.object({ storeName: z.string(), avgTotal: z.number(), tripCount: z.number(), - }) + }), ), priceAlerts: z.array( z.object({ @@ -82,20 +84,20 @@ export const PriceAnalyticsResponseSchema = z.object({ 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(), - }) + }), ), }); diff --git a/packages/shared/src/validation/shopping-list.schemas.ts b/packages/shared/src/validation/shopping-list.schemas.ts index b26c00b..bdbe4df 100644 --- a/packages/shared/src/validation/shopping-list.schemas.ts +++ b/packages/shared/src/validation/shopping-list.schemas.ts @@ -22,16 +22,19 @@ export const ShoppingItemSchema = z.object({ 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([]), + 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({ @@ -41,17 +44,18 @@ export const UpdateShoppingListSchema = z.object({ 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 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(), @@ -68,10 +72,12 @@ export const ShoppingListResponseSchema = z.object({ name: z.string(), status: z.nativeEnum(ShoppingListStatus), items: z.array(ShoppingItemSchema), - createdFrom: z.object({ - type: z.nativeEnum(ShoppingListSourceType), - referenceId: z.string().optional(), - }).optional(), + createdFrom: z + .object({ + type: z.nativeEnum(ShoppingListSourceType), + referenceId: z.string().optional(), + }) + .optional(), mealPlanId: z.string().optional(), totalEstimatedCost: z.number().optional(), preferredStoreId: z.string().optional(), @@ -93,20 +99,22 @@ export const StoreComparisonResultSchema = z.object({ 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(), + 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; diff --git a/packages/web/src/app/(dashboard)/products/ProductModal.tsx b/packages/web/src/app/(dashboard)/products/ProductModal.tsx index 86dca00..56bf577 100644 --- a/packages/web/src/app/(dashboard)/products/ProductModal.tsx +++ b/packages/web/src/app/(dashboard)/products/ProductModal.tsx @@ -190,7 +190,7 @@ export function ProductModal({ {error &&

{error}

} -
+ {/* Basic info */}
diff --git a/packages/web/src/app/(dashboard)/products/__tests__/ImportDialog.test.tsx b/packages/web/src/app/(dashboard)/products/__tests__/ImportDialog.test.tsx index 88285d3..da493d9 100644 --- a/packages/web/src/app/(dashboard)/products/__tests__/ImportDialog.test.tsx +++ b/packages/web/src/app/(dashboard)/products/__tests__/ImportDialog.test.tsx @@ -36,10 +36,9 @@ describe('ImportDialog', () => { 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(); - fireEvent.click(screen.getByText('Upload')); - expect(screen.getByText('Please select a file')).toBeInTheDocument(); + expect(screen.getByText('Upload')).toBeDisabled(); }); it('shows file name after selection', () => { diff --git a/packages/web/src/app/(dashboard)/products/__tests__/ProductModal.test.tsx b/packages/web/src/app/(dashboard)/products/__tests__/ProductModal.test.tsx index b22dede..a9b0656 100644 --- a/packages/web/src/app/(dashboard)/products/__tests__/ProductModal.test.tsx +++ b/packages/web/src/app/(dashboard)/products/__tests__/ProductModal.test.tsx @@ -108,11 +108,11 @@ describe('ProductModal', () => { // Fill nutrition const numberInputs = screen.getAllByRole('spinbutton'); - // serving size is index 0, calories=1, protein=2, carbs=3, fat=4 - fireEvent.change(numberInputs[1]!, { target: { value: '165' } }); - fireEvent.change(numberInputs[2]!, { target: { value: '31' } }); - fireEvent.change(numberInputs[3]!, { target: { value: '0' } }); - fireEvent.change(numberInputs[4]!, { target: { value: '3.6' } }); + // serving size is index 0, density=1, calories=2, protein=3, carbs=4, fat=5 + fireEvent.change(numberInputs[2]!, { target: { value: '165' } }); + fireEvent.change(numberInputs[3]!, { target: { value: '31' } }); + fireEvent.change(numberInputs[4]!, { target: { value: '0' } }); + fireEvent.change(numberInputs[5]!, { target: { value: '3.6' } }); fireEvent.click(screen.getByText('Save')); @@ -141,10 +141,10 @@ describe('ProductModal', () => { fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '50' } }); const numberInputs = screen.getAllByRole('spinbutton'); - fireEvent.change(numberInputs[1]!, { target: { value: '10' } }); - fireEvent.change(numberInputs[2]!, { target: { value: '5' } }); - fireEvent.change(numberInputs[3]!, { target: { value: '2' } }); - fireEvent.change(numberInputs[4]!, { target: { value: '1' } }); + fireEvent.change(numberInputs[2]!, { target: { value: '10' } }); + fireEvent.change(numberInputs[3]!, { target: { value: '5' } }); + fireEvent.change(numberInputs[4]!, { target: { value: '2' } }); + fireEvent.change(numberInputs[5]!, { target: { value: '1' } }); fireEvent.click(screen.getByText('Save')); @@ -163,10 +163,10 @@ describe('ProductModal', () => { fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '50' } }); const numberInputs = screen.getAllByRole('spinbutton'); - fireEvent.change(numberInputs[1]!, { target: { value: '10' } }); - fireEvent.change(numberInputs[2]!, { target: { value: '5' } }); - fireEvent.change(numberInputs[3]!, { target: { value: '2' } }); - fireEvent.change(numberInputs[4]!, { target: { value: '1' } }); + fireEvent.change(numberInputs[2]!, { target: { value: '10' } }); + fireEvent.change(numberInputs[3]!, { target: { value: '5' } }); + fireEvent.change(numberInputs[4]!, { target: { value: '2' } }); + fireEvent.change(numberInputs[5]!, { target: { value: '1' } }); fireEvent.click(screen.getByText('Save')); @@ -185,10 +185,10 @@ describe('ProductModal', () => { fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '50' } }); const numberInputs = screen.getAllByRole('spinbutton'); - fireEvent.change(numberInputs[1]!, { target: { value: '10' } }); - fireEvent.change(numberInputs[2]!, { target: { value: '5' } }); - fireEvent.change(numberInputs[3]!, { target: { value: '2' } }); - fireEvent.change(numberInputs[4]!, { target: { value: '1' } }); + fireEvent.change(numberInputs[2]!, { target: { value: '10' } }); + fireEvent.change(numberInputs[3]!, { target: { value: '5' } }); + fireEvent.change(numberInputs[4]!, { target: { value: '2' } }); + fireEvent.change(numberInputs[5]!, { target: { value: '1' } }); fireEvent.click(screen.getByText('Save')); diff --git a/packages/web/src/components/__tests__/Sidebar.test.tsx b/packages/web/src/components/__tests__/Sidebar.test.tsx index 0fcbb07..576573b 100644 --- a/packages/web/src/components/__tests__/Sidebar.test.tsx +++ b/packages/web/src/components/__tests__/Sidebar.test.tsx @@ -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'; const { mockUsePathname } = vi.hoisted(() => ({ diff --git a/packages/web/src/components/__tests__/ThemeProvider.test.tsx b/packages/web/src/components/__tests__/ThemeProvider.test.tsx index abd8537..b5c3178 100644 --- a/packages/web/src/components/__tests__/ThemeProvider.test.tsx +++ b/packages/web/src/components/__tests__/ThemeProvider.test.tsx @@ -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 { ThemeProvider, useTheme } from '../ThemeProvider'; import { Providers } from '../Providers'; diff --git a/packages/web/test-output.txt b/packages/web/test-output.txt new file mode 100644 index 0000000000000000000000000000000000000000..35ada1eaa009d6032f104d2f53af028ff7af937c GIT binary patch literal 35286 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