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": "*"
}
},
"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",

View file

@ -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",

View file

@ -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 } : {}),

View file

@ -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();

View file

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

View file

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

View file

@ -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', () => {

View file

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

View file

@ -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(
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),
}),
)
.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(),
})
}),
),
});

View file

@ -22,7 +22,8 @@ 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(
items: z
.array(
z.object({
productId: z.string().optional(),
customName: z.string().optional(),
@ -30,8 +31,10 @@ export const CreateShoppingListSchema = z.object({
unit: z.nativeEnum(ServingUnit),
notes: z.string().optional(),
category: z.nativeEnum(ProductCategory).optional(),
})
).optional().default([]),
}),
)
.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({
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' }
);
})
.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({
createdFrom: z
.object({
type: z.nativeEnum(ShoppingListSourceType),
referenceId: z.string().optional(),
}).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({
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(),
})
.optional(),
});
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>}
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<form onSubmit={handleSubmit} noValidate style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Basic info */}
<div>
<label style={labelStyle}>Name *</label>

View file

@ -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(<ImportDialog {...defaultProps} />);
fireEvent.click(screen.getByText('Upload'));
expect(screen.getByText('Please select a file')).toBeInTheDocument();
expect(screen.getByText('Upload')).toBeDisabled();
});
it('shows file name after selection', () => {

View file

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

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';
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 { ThemeProvider, useTheme } from '../ThemeProvider';
import { Providers } from '../Providers';

Binary file not shown.