diff --git a/.agents/skills/grill-with-docs/SKILL.md b/.agents/skills/grill-with-docs/SKILL.md new file mode 100644 index 0000000..9cb37c9 --- /dev/null +++ b/.agents/skills/grill-with-docs/SKILL.md @@ -0,0 +1,88 @@ +--- +name: grill-with-docs +description: Grilling session that challenges your plan against the existing domain model, sharpens terminology, and updates documentation (CONTEXT.md, ADRs) inline as decisions crystallise. Use when user wants to stress-test a plan against their project's language and documented decisions. +--- + + + +Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer. + +Ask the questions one at a time, waiting for feedback on each question before continuing. + +If a question can be answered by exploring the codebase, explore the codebase instead. + + + + + +## Domain awareness + +During codebase exploration, also look for existing documentation: + +### File structure + +Most repos have a single context: + +``` +/ +├── CONTEXT.md +├── docs/ +│ └── adr/ +│ ├── 0001-event-sourced-orders.md +│ └── 0002-postgres-for-write-model.md +└── src/ +``` + +If a `CONTEXT-MAP.md` exists at the root, the repo has multiple contexts. The map points to where each one lives: + +``` +/ +├── CONTEXT-MAP.md +├── docs/ +│ └── adr/ ← system-wide decisions +├── src/ +│ ├── ordering/ +│ │ ├── CONTEXT.md +│ │ └── docs/adr/ ← context-specific decisions +│ └── billing/ +│ ├── CONTEXT.md +│ └── docs/adr/ +``` + +Create files lazily — only when you have something to write. If no `CONTEXT.md` exists, create one when the first term is resolved. If no `docs/adr/` exists, create it when the first ADR is needed. + +## During the session + +### Challenge against the glossary + +When the user uses a term that conflicts with the existing language in `CONTEXT.md`, call it out immediately. "Your glossary defines 'cancellation' as X, but you seem to mean Y — which is it?" + +### Sharpen fuzzy language + +When the user uses vague or overloaded terms, propose a precise canonical term. "You're saying 'account' — do you mean the Customer or the User? Those are different things." + +### Discuss concrete scenarios + +When domain relationships are being discussed, stress-test them with specific scenarios. Invent scenarios that probe edge cases and force the user to be precise about the boundaries between concepts. + +### Cross-reference with code + +When the user states how something works, check whether the code agrees. If you find a contradiction, surface it: "Your code cancels entire Orders, but you just said partial cancellation is possible — which is right?" + +### Update CONTEXT.md inline + +When a term is resolved, update `CONTEXT.md` right there. Don't batch these up — capture them as they happen. Use the format in [CONTEXT-FORMAT.md](./CONTEXT-FORMAT.md). + +`CONTEXT.md` should be totally devoid of implementation details. Do not treat `CONTEXT.md` as a spec, a scratch pad, or a repository for implementation decisions. It is a glossary and nothing else. + +### Offer ADRs sparingly + +Only offer to create an ADR when all three are true: + +1. **Hard to reverse** — the cost of changing your mind later is meaningful +2. **Surprising without context** — a future reader will wonder "why did they do it this way?" +3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons + +If any of the three is missing, skip the ADR. Use the format in [ADR-FORMAT.md](./ADR-FORMAT.md). + + \ No newline at end of file diff --git a/ANTIGRAVITY.md b/ANTIGRAVITY.md index 86b2a61..a0ea314 100644 --- a/ANTIGRAVITY.md +++ b/ANTIGRAVITY.md @@ -48,9 +48,14 @@ docker compose -f docker/docker-compose.yml down # Stop all services ## Implementation Workflow -Every feature or phase implementation must follow this "Vertical Slice" approach: +Every feature or phase implementation MUST start with a rigorous planning and design phase: -1. **Shared Layer**: Define types, enums, and Zod v4 schemas in `packages/shared`. Add unit tests. +0. **Mandatory Planning (Grilling)**: Before starting any feature or bug fix, you MUST invoke the `grill-with-docs` skill. + - Run the skill using: `view_file` on `.agents/skills/grill-with-docs/SKILL.md` and follow its instructions. + - This session will stress-test your plan against the existing domain model, terminology, and documentation (`CONTEXT.md`, ADRs). + - Decisions must be crystallized and documentation (glossary/ADRs) updated before moving to implementation. + +1. **Vertical Slice Implementation**: Follow this approach for the actual build: 2. **Database Layer**: Create the Mongoose schema and Repository in `packages/api`. All reads must use `.lean().exec()`. 3. **Service Layer**: Implement business logic in the Service class, using Awilix for constructor injection. Add unit tests. 4. **Route Layer**: Create the Fastify route plugin and register it. Add route tests (using `app.inject`). diff --git a/packages/api/src/modules/cabinet/cabinet.repository.ts b/packages/api/src/modules/cabinet/cabinet.repository.ts index 180801d..71ffc0c 100644 --- a/packages/api/src/modules/cabinet/cabinet.repository.ts +++ b/packages/api/src/modules/cabinet/cabinet.repository.ts @@ -34,7 +34,7 @@ export class CabinetRepository { const limit = query.limit; const items = await CabinetItemModel.find(filter) - .sort({ _id: 1 }) + .sort({ medicineName: 1, medicineForm: 1, medicineStrength: 1, _id: 1 }) .limit(limit + 1) .lean() .exec(); @@ -67,7 +67,7 @@ export class CabinetRepository { itemCount: { $sum: 1 }, }, }, - { $sort: { medicineName: 1 } }, + { $sort: { medicineName: 1, medicineForm: 1, medicineStrength: 1, _id: 1 } }, ]).exec(); } diff --git a/packages/api/src/modules/meal-plans/meal-plans.repository.test.ts b/packages/api/src/modules/meal-plans/meal-plans.repository.test.ts index 3a2a474..6160a06 100644 --- a/packages/api/src/modules/meal-plans/meal-plans.repository.test.ts +++ b/packages/api/src/modules/meal-plans/meal-plans.repository.test.ts @@ -90,4 +90,59 @@ describe(MealPlanRepository.name, () => { ); }); }); + + describe('findById', () => { + it('finds meal plan by id and householdId', async () => { + const mockPlan = { _id: 'mp1', weekStartDate: '2026-05-18' }; + vi.mocked(MealPlanModel.findOne).mockReturnValue(makeChain(mockPlan) as never); + + const result = await repo.findById('mp1', 'hh1'); + expect(MealPlanModel.findOne).toHaveBeenCalledWith({ + _id: 'mp1', + householdId: 'hh1', + }); + expect(result).toEqual(mockPlan); + }); + }); + + describe('update', () => { + it('updates meal plan using findOneAndUpdate', async () => { + vi.mocked(MealPlanModel.findOneAndUpdate).mockReturnValue(makeChain({ _id: 'mp1' }) as never); + + await repo.update('mp1', 'hh1', { status: MealPlanStatus.ACTIVE }); + expect(MealPlanModel.findOneAndUpdate).toHaveBeenCalledWith( + { _id: 'mp1', householdId: 'hh1' }, + { $set: { status: MealPlanStatus.ACTIVE } }, + { new: true, lean: true }, + ); + }); + }); + + describe('delete', () => { + it('deletes meal plan using findOneAndDelete', async () => { + vi.mocked(MealPlanModel.findOneAndDelete).mockReturnValue(makeChain({ _id: 'mp1' }) as never); + + const result = await repo.delete('mp1', 'hh1'); + expect(MealPlanModel.findOneAndDelete).toHaveBeenCalledWith({ + _id: 'mp1', + householdId: 'hh1', + }); + expect(result).toEqual({ _id: 'mp1' }); + }); + }); + + describe('findByHousehold pagination cursor', () => { + it('applies pagination filter when cursor is provided', async () => { + const chain = makeChain([]); + vi.mocked(MealPlanModel.find).mockReturnValue(chain as never); + + const cursor = Buffer.from('some-mongo-id').toString('base64'); + await repo.findByHousehold('hh1', { limit: 20, cursor }); + + expect(MealPlanModel.find).toHaveBeenCalledWith({ + householdId: 'hh1', + _id: { $gt: 'some-mongo-id' } + }); + }); + }); }); diff --git a/packages/api/src/modules/meal-plans/meal-plans.routes.test.ts b/packages/api/src/modules/meal-plans/meal-plans.routes.test.ts index b658fe9..a135d75 100644 --- a/packages/api/src/modules/meal-plans/meal-plans.routes.test.ts +++ b/packages/api/src/modules/meal-plans/meal-plans.routes.test.ts @@ -243,4 +243,88 @@ describe('meal-plan.routes', () => { expect(Array.isArray(body.missingItems)).toBe(true); }); }); + + describe('GET /api/v1/households/:householdId/meal-plans/:id', () => { + it('returns plan if found', async () => { + const planWithMeal = makePlan({ + createdAt: new Date(), + days: [{ + date: '2026-05-10', + meals: [{ + id: '123e4567-e89b-42d3-a456-426614174000', + type: 'dinner', + recipeId: 'recipe-1', + recipeName: 'Spaghetti', + servings: 2, + perServingNutrition: emptyNutrition, + customName: 'My Pasta', + customNutrition: emptyNutrition, + notes: 'Very yummy', + }], + dailyNutritionTotal: emptyNutrition, + }] + }); + mockFindById.mockResolvedValue(planWithMeal); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/meal-plans/plan-1', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()._id).toBe('plan-1'); + expect(res.json().days[0].meals).toHaveLength(1); + }); + }); + + describe('PATCH /api/v1/households/:householdId/meal-plans/:id', () => { + it('updates plan content and returns it', async () => { + mockUpdate.mockResolvedValue(makePlan({ status: MealPlanStatus.ACTIVE })); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/v1/households/hh1/meal-plans/plan-1', + headers: { ...authHeaders, 'content-type': 'application/json' }, + body: JSON.stringify({ + status: MealPlanStatus.ACTIVE, + }), + }); + + expect(res.statusCode).toBe(200); + expect(res.json().status).toBe(MealPlanStatus.ACTIVE); + }); + }); + + describe('PATCH /api/v1/households/:householdId/meal-plans/:id/status', () => { + it('updates plan status directly and returns it', async () => { + mockUpdateStatus.mockResolvedValue(makePlan({ status: MealPlanStatus.ACTIVE })); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/v1/households/hh1/meal-plans/plan-1/status', + headers: { ...authHeaders, 'content-type': 'application/json' }, + body: JSON.stringify({ + status: MealPlanStatus.ACTIVE, + }), + }); + + expect(res.statusCode).toBe(200); + expect(res.json().status).toBe(MealPlanStatus.ACTIVE); + }); + }); + + describe('DELETE /api/v1/households/:householdId/meal-plans/:id', () => { + it('deletes the plan and returns 204', async () => { + mockDelete.mockResolvedValue(makePlan()); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/v1/households/hh1/meal-plans/plan-1', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(204); + }); + }); }); diff --git a/packages/api/src/modules/meal-plans/meal-plans.service.test.ts b/packages/api/src/modules/meal-plans/meal-plans.service.test.ts index 7060e0f..9e97265 100644 --- a/packages/api/src/modules/meal-plans/meal-plans.service.test.ts +++ b/packages/api/src/modules/meal-plans/meal-plans.service.test.ts @@ -211,6 +211,18 @@ describe(MealPlanService.name, () => { }); expect(result.status).toBe(MealPlanStatus.ACTIVE); }); + + it('supports updating shoppingListId', async () => { + mockRepo.update.mockImplementation((id, hh, data) => Promise.resolve({ ...existingPlan, ...data })); + const result = await service.update('p1', 'hh1', { shoppingListId: 'sl-1' }); + expect(mockRepo.update).toHaveBeenCalledWith('p1', 'hh1', { shoppingListId: 'sl-1' }); + expect((result as any).shoppingListId).toBe('sl-1'); + }); + + it('throws NotFoundError if update returns null', async () => { + mockRepo.update.mockResolvedValue(null); + await expect(service.update('p1', 'hh1', { status: MealPlanStatus.ACTIVE })).rejects.toThrow(NotFoundError); + }); }); describe('updateStatus', () => { @@ -222,6 +234,12 @@ describe(MealPlanService.name, () => { expect(mockRepo.updateStatus).toHaveBeenCalledWith('p1', 'hh1', MealPlanStatus.ARCHIVED); expect(result.status).toBe(MealPlanStatus.ARCHIVED); }); + + it('throws NotFoundError if updateStatus returns null', async () => { + mockRepo.findById.mockResolvedValue({ _id: 'p1' }); + mockRepo.updateStatus.mockResolvedValue(null); + await expect(service.updateStatus('p1', 'hh1', MealPlanStatus.ARCHIVED)).rejects.toThrow(NotFoundError); + }); }); describe('delete', () => { @@ -233,5 +251,11 @@ describe(MealPlanService.name, () => { expect(mockRepo.delete).toHaveBeenCalledWith('p1', 'hh1'); expect(result).toEqual({ _id: 'p1' }); }); + + it('throws NotFoundError if delete returns null', async () => { + mockRepo.findById.mockResolvedValue({ _id: 'p1' }); + mockRepo.delete.mockResolvedValue(null); + await expect(service.delete('p1', 'hh1')).rejects.toThrow(NotFoundError); + }); }); }); diff --git a/packages/api/src/modules/meal-plans/meal-plans.service.ts b/packages/api/src/modules/meal-plans/meal-plans.service.ts index 2066b77..bc7cf65 100644 --- a/packages/api/src/modules/meal-plans/meal-plans.service.ts +++ b/packages/api/src/modules/meal-plans/meal-plans.service.ts @@ -114,7 +114,7 @@ export class MealPlanService { /** Calculates standard daily totals based on the component meals. */ private computeDayTotals(day: MealPlanDay): MealPlanDay { - const total: NutritionInfo = { + const total = { calories: 0, protein: 0, carbs: 0, @@ -136,14 +136,14 @@ export class MealPlanService { total.carbs += source.carbs * servings; total.fat += source.fat * servings; - if (source.fiber != null) total.fiber = (total.fiber ?? 0) + source.fiber * servings; - if (source.sugar != null) total.sugar = (total.sugar ?? 0) + source.sugar * servings; - if (source.sodium != null) total.sodium = (total.sodium ?? 0) + source.sodium * servings; + if (source.fiber != null) total.fiber += source.fiber * servings; + if (source.sugar != null) total.sugar += source.sugar * servings; + if (source.sodium != null) total.sodium += source.sodium * servings; if (source.saturatedFat != null) { - total.saturatedFat = (total.saturatedFat ?? 0) + source.saturatedFat * servings; + total.saturatedFat += source.saturatedFat * servings; } if (source.cholesterol != null) { - total.cholesterol = (total.cholesterol ?? 0) + source.cholesterol * servings; + total.cholesterol += source.cholesterol * servings; } } @@ -155,11 +155,11 @@ export class MealPlanService { protein: Math.round(total.protein * 100) / 100, carbs: Math.round(total.carbs * 100) / 100, fat: Math.round(total.fat * 100) / 100, - fiber: Math.round((total.fiber ?? 0) * 100) / 100, - sugar: Math.round((total.sugar ?? 0) * 100) / 100, - sodium: Math.round((total.sodium ?? 0) * 100) / 100, - saturatedFat: Math.round((total.saturatedFat ?? 0) * 100) / 100, - cholesterol: Math.round((total.cholesterol ?? 0) * 100) / 100, + fiber: Math.round(total.fiber * 100) / 100, + sugar: Math.round(total.sugar * 100) / 100, + sodium: Math.round(total.sodium * 100) / 100, + saturatedFat: Math.round(total.saturatedFat * 100) / 100, + cholesterol: Math.round(total.cholesterol * 100) / 100, }, }; } diff --git a/packages/api/src/modules/meal-plans/shopping-gap.service.test.ts b/packages/api/src/modules/meal-plans/shopping-gap.service.test.ts index 837b6f1..e66e93e 100644 --- a/packages/api/src/modules/meal-plans/shopping-gap.service.test.ts +++ b/packages/api/src/modules/meal-plans/shopping-gap.service.test.ts @@ -106,5 +106,129 @@ describe(ShoppingGapService.name, () => { const result = await service.calculateGap('hh1', 'plan2'); expect(result.missingItems.length).toBe(0); }); + + it('aggregates duplicate ingredients and sorts by product name', async () => { + mockMealRepo.findById.mockResolvedValue({ + _id: 'plan-multi', + days: [ + { + meals: [ + { recipeId: 'recipeA', servings: 1 }, + { recipeId: 'recipeB', servings: 1 }, + ] + } + ] + }); + + mockRecipesRepo.findById.mockImplementation(async (id) => { + if (id === 'recipeA') { + return { + _id: 'recipeA', servings: 1, + ingredients: [{ productId: 'prod1', quantity: 10, isOptional: false }] + }; + } + return { + _id: 'recipeB', servings: 1, + ingredients: [ + { productId: 'prod1', quantity: 20, isOptional: false }, + { productId: 'prod2', quantity: 5, isOptional: false }, + ] + }; + }); + + mockProductsRepo.findByIds.mockResolvedValue([ + { _id: 'prod1', name: 'Banana' }, + { _id: 'prod2', name: 'Apple' }, + ]); + + mockPantryRepo.findActiveByHousehold.mockResolvedValue([]); + + const result = await service.calculateGap('hh1', 'plan-multi'); + + expect(result.missingItems).toHaveLength(2); + expect(result.missingItems[0].productName).toBe('Apple'); + expect(result.missingItems[1].productName).toBe('Banana'); + expect(result.missingItems[1].requiredQuantity).toBe(30); + }); + + it('covers fallback paths for missing list, recipe properties and pantry quantities', async () => { + mockMealRepo.findById.mockResolvedValue({ + _id: 'plan-empty', + }); + mockProductsRepo.findByIds.mockResolvedValue([]); + mockPantryRepo.findActiveByHousehold.mockResolvedValue([]); + + let res = await service.calculateGap('hh1', 'plan-empty'); + expect(res.missingItems).toHaveLength(0); + + mockMealRepo.findById.mockResolvedValue({ + _id: 'plan-missing', + days: [ + { + meals: [{ recipeId: 'recipeC', servings: 1 }] + } + ] + }); + + mockRecipesRepo.findById.mockResolvedValue({ + _id: 'recipeC', + servings: 1, + ingredients: [ + { productId: 'prod3', quantity: 10, isOptional: false } + ] + }); + + mockProductsRepo.findByIds.mockResolvedValue([ + { _id: 'prod3' } + ]); + + mockPantryRepo.findActiveByHousehold.mockResolvedValue([ + { productId: 'prod3' } + ]); + + res = await service.calculateGap('hh1', 'plan-missing'); + expect(res.missingItems).toHaveLength(1); + const itm = res.missingItems[0]!; + expect(itm.unit).toBe('g'); + expect(itm.productName).toBe('Unknown Ingredient'); + expect(itm.category).toBe('other'); + expect(itm.pantryQuantity).toBe(0); + }); + + it('skips optional ingredients, handles missing recipes and defaults servings to 1', async () => { + mockMealRepo.findById.mockResolvedValue({ + _id: 'plan-edge', + days: [ + { + meals: [ + { recipeId: 'recipeExist', servings: 2 }, + { recipeId: 'recipeNotExist', servings: 1 }, + ] + } + ] + }); + + mockRecipesRepo.findById.mockImplementation(async (id) => { + if (id === 'recipeExist') { + return { + _id: 'recipeExist', + servings: 0, + ingredients: [ + { productId: 'prodIng', quantity: 5, isOptional: false }, + { productId: 'prodOptional', quantity: 10, isOptional: true }, + ] + }; + } + return null; + }); + + mockProductsRepo.findByIds.mockResolvedValue([{ _id: 'prodIng', name: 'Ingredient' }]); + mockPantryRepo.findActiveByHousehold.mockResolvedValue([]); + + const result = await service.calculateGap('hh1', 'plan-edge'); + expect(result.missingItems).toHaveLength(1); + expect(result.missingItems[0].productId).toBe('prodIng'); + expect(result.missingItems[0].requiredQuantity).toBe(10); + }); }); }); diff --git a/packages/api/src/modules/meal-plans/suggestion-engine.service.test.ts b/packages/api/src/modules/meal-plans/suggestion-engine.service.test.ts index f3a8895..09c743e 100644 --- a/packages/api/src/modules/meal-plans/suggestion-engine.service.test.ts +++ b/packages/api/src/modules/meal-plans/suggestion-engine.service.test.ts @@ -161,5 +161,112 @@ describe(SuggestionEngineService.name, () => { // Variety calculation: 7 days ago / 14 days = 0.5 expect(suggestions[0]!.scores.variety).toBeCloseTo(0.5, 1); }); + + it('triggers reasoning branches for partial coverage and urgent items', async () => { + const recipeC = { + _id: 'recipeC', + name: 'Recipe C', + ingredients: [ + { productId: 'prod1', quantity: 10, isOptional: false }, + { productId: 'prod2', quantity: 10, isOptional: false }, + ], + }; + + mockRecipesRepo.findByHousehold.mockResolvedValue({ data: [recipeC] }); + + // 1. Coverage: (10/10 + 5/10)/2 = 0.75 (hits >0.5) + // 2. Urgency: both set to urgent = 1.0 (hits >0.7) + mockPantryRepo.findActiveByHousehold.mockResolvedValue([ + { productId: 'prod1', quantity: 10, freshnessEstimate: { urgency: 'urgent' } }, + { productId: 'prod2', quantity: 5, freshnessEstimate: { urgency: 'urgent' } }, + ]); + mockNutritionRepo.findByUser.mockResolvedValue(null); + mockMealPlanRepo.findByHousehold.mockResolvedValue({ data: [] }); + + const suggestions = await service.getSuggestions('hh1', 'user1'); + expect(suggestions[0]!.scores.coverage).toBe(0.75); + expect(suggestions[0]!.scores.urgency).toBe(1); + expect(suggestions[0]!.reasoning).toContain('Uses several ingredients already stocked in your pantry.'); + expect(suggestions[0]!.reasoning).toContain('High priority: Saves expiring pantry items from going to waste!'); + }); + + it('triggers reasoning for moderately soon-to-expire items', async () => { + const recipeD = { + _id: 'recipeD', + name: 'Recipe D', + ingredients: [{ productId: 'prod1', quantity: 5, isOptional: false }], + }; + + mockRecipesRepo.findByHousehold.mockResolvedValue({ data: [recipeD] }); + + // Urgency soon/expiringSoon has weight 0.7 (hits >0.4 branch) + mockPantryRepo.findActiveByHousehold.mockResolvedValue([ + { productId: 'prod1', quantity: 5, freshnessEstimate: { urgency: 'expiringSoon' } }, + ]); + mockNutritionRepo.findByUser.mockResolvedValue(null); + mockMealPlanRepo.findByHousehold.mockResolvedValue({ data: [] }); + + const suggestions = await service.getSuggestions('hh1', 'user1'); + expect(suggestions[0]!.scores.urgency).toBe(0.7); + expect(suggestions[0]!.reasoning).toContain('Helps use up items that should be consumed soon.'); + }); + + it('aggregates duplicate pantry items and handles normal/default urgencies', async () => { + const recipeE = { + _id: 'recipeE', + name: 'Recipe E', + ingredients: [ + { productId: 'prod1', quantity: 5, isOptional: false }, + ], + }; + + mockRecipesRepo.findByHousehold.mockResolvedValue({ data: [recipeE] }); + + mockPantryRepo.findActiveByHousehold.mockResolvedValue([ + { productId: 'prod1', quantity: 2, freshnessEstimate: { daysRemaining: 5, urgency: 'normal' } }, + { productId: 'prod1', quantity: 3, freshnessEstimate: { daysRemaining: 10, urgency: 'unknown-type' } }, + ]); + mockNutritionRepo.findByUser.mockResolvedValue(null); + mockMealPlanRepo.findByHousehold.mockResolvedValue({ data: [] }); + + const suggestions = await service.getSuggestions('hh1', 'user1'); + expect(suggestions[0]!.scores.coverage).toBe(1); + expect(suggestions[0]!.scores.urgency).toBe(0.3); + }); + + it('covers boundary logic for nameless recipes, custom meals, default targets and private weights', async () => { + // 1. Nameless recipe and recipe without ingredients + const rawRecipe = { _id: 'recipeMissingProps' }; + mockRecipesRepo.findByHousehold.mockResolvedValue({ data: [rawRecipe] }); + + // 2. Active target with partial/falsy info + mockNutritionRepo.findByUser.mockResolvedValue({ dailyCalories: 0, proteinG: 0 }); + + // 3. Last eaten containing a custom meal without recipeId (should continue) + mockMealPlanRepo.findByHousehold.mockResolvedValue({ + data: [ + { + days: [ + { + date: '2026-05-19', + meals: [ + { customName: 'Snack' }, // no recipeId! + ], + }, + ], + }, + ], + }); + + mockPantryRepo.findActiveByHousehold.mockResolvedValue([]); + + const suggestions = await service.getSuggestions('hh1', 'user1'); + + expect(suggestions).toHaveLength(1); + + // 4. Direct call to getUrgencyWeight default branch + const defaultWeight = (service as any).getUrgencyWeight('mystery-status'); + expect(defaultWeight).toBe(0); + }); }); }); diff --git a/packages/api/src/modules/nutrition-targets/nutrition-target.repository.test.ts b/packages/api/src/modules/nutrition-targets/nutrition-target.repository.test.ts index 13608fe..a30943b 100644 --- a/packages/api/src/modules/nutrition-targets/nutrition-target.repository.test.ts +++ b/packages/api/src/modules/nutrition-targets/nutrition-target.repository.test.ts @@ -91,4 +91,19 @@ describe(NutritionTargetRepository.name, () => { ); }); }); + + describe('update', () => { + it('updates specific target using findOneAndUpdate', async () => { + const updatedDoc = { _id: 't1', dailyCalories: 2100 }; + vi.mocked(NutritionTargetModel.findOneAndUpdate).mockReturnValue(makeChain(updatedDoc) as never); + + const result = await repo.update('t1', 'user1', 'hh1', { dailyCalories: 2100 }); + expect(NutritionTargetModel.findOneAndUpdate).toHaveBeenCalledWith( + { _id: 't1', userId: 'user1', householdId: 'hh1' }, + { $set: { dailyCalories: 2100 } }, + { new: true, lean: true } + ); + expect(result).toEqual(updatedDoc); + }); + }); }); diff --git a/packages/api/src/modules/nutrition-targets/nutrition-target.routes.test.ts b/packages/api/src/modules/nutrition-targets/nutrition-target.routes.test.ts index 071ed8e..826f22e 100644 --- a/packages/api/src/modules/nutrition-targets/nutrition-target.routes.test.ts +++ b/packages/api/src/modules/nutrition-targets/nutrition-target.routes.test.ts @@ -129,8 +129,18 @@ describe('nutrition-target.routes', () => { }); describe('GET /api/v1/households/:householdId/nutrition-targets/history', () => { - it('returns historical targets', async () => { - mockFindAllByUser.mockResolvedValue([makeTarget({ isActive: false }), makeTarget()]); + it('returns historical targets with optional fields and object _id', async () => { + mockFindAllByUser.mockResolvedValue([ + makeTarget({ + _id: { toString: () => 'target-1' }, + isActive: false, + fiberG: 30, + sugarG: 50, + sodiumMg: 2000, + createdAt: new Date(), + }), + makeTarget() + ]); const res = await app.inject({ method: 'GET', @@ -141,6 +151,8 @@ describe('nutrition-target.routes', () => { expect(res.statusCode).toBe(200); const body = res.json(); expect(body).toHaveLength(2); + expect(body[0].fiberG).toBe(30); + expect(body[0]._id).toBe('target-1'); }); }); diff --git a/packages/api/src/modules/pantry/pantry.repository.ts b/packages/api/src/modules/pantry/pantry.repository.ts index b88516a..5dc8932 100644 --- a/packages/api/src/modules/pantry/pantry.repository.ts +++ b/packages/api/src/modules/pantry/pantry.repository.ts @@ -92,6 +92,7 @@ export class PantryRepository { householdId, status: { $in: ['sealed', 'opened', 'prepared'] }, }) + .sort({ 'freshnessEstimate.daysRemaining': 1, _id: 1 }) .lean() .exec(); } diff --git a/packages/api/src/modules/prices/prices.routes.test.ts b/packages/api/src/modules/prices/prices.routes.test.ts index b572d5d..4a87931 100644 --- a/packages/api/src/modules/prices/prices.routes.test.ts +++ b/packages/api/src/modules/prices/prices.routes.test.ts @@ -111,7 +111,13 @@ describe('prices.routes', () => { describe('POST /api/v1/households/:householdId/prices', () => { it('records price and returns 201 response', async () => { - mockCreate.mockResolvedValue(makeRecord()); + mockCreate.mockResolvedValue( + makeRecord({ + receiptImageUrl: 'http://test.com/img.jpg', + notes: 'Custom notes', + date: '2026-05-14T00:00:00.000Z', + }) + ); const res = await app.inject({ method: 'POST', @@ -179,4 +185,34 @@ describe('prices.routes', () => { expect(typeof body.priceAlerts[0].date).toBe('string'); }); }); + + describe('POST /api/v1/households/:householdId/prices/bulk', () => { + it('records bulk prices and returns 201', async () => { + mockCreateMany.mockResolvedValue([makeRecord()]); + const res = await app.inject({ + method: 'POST', + url: '/api/v1/households/hh1/prices/bulk', + headers: { ...authHeaders, 'content-type': 'application/json' }, + body: JSON.stringify({ + storeId: 's1', + items: [{ productId: 'p1', price: 10, quantity: 1, unit: 'piece' }], + }), + }); + expect(res.statusCode).toBe(201); + expect(res.json()[0].productName).toBe('Apples'); + }); + }); + + describe('GET /api/v1/households/:householdId/prices/compare/:productId', () => { + it('returns comparison array', async () => { + mockCompareStores.mockResolvedValue([{ storeId: 's1', storeName: 'Store', latestPrice: 10, latestPricePerUnit: 10, currency: 'USD', date: new Date() }]); + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/prices/compare/p1', + headers: authHeaders, + }); + expect(res.statusCode).toBe(200); + expect(res.json().data).toHaveLength(1); + }); + }); }); diff --git a/packages/api/src/modules/prices/prices.service.test.ts b/packages/api/src/modules/prices/prices.service.test.ts index 9d3ba32..e3d566a 100644 --- a/packages/api/src/modules/prices/prices.service.test.ts +++ b/packages/api/src/modules/prices/prices.service.test.ts @@ -54,6 +54,26 @@ describe('PricesService', () => { expect(result._id).toBe('rec1'); }); + it('handles zero quantity and defaults date to current when recording price', async () => { + mockProductsRepo.findById.mockResolvedValue({ name: 'Bread' }); + mockStoresRepo.findById.mockResolvedValue({ name: 'Aldi' }); + mockPricesRepo.create.mockImplementation(arg => Promise.resolve({ ...arg, _id: 'rec1' })); + + const result = await service.recordPrice( + { productId: 'p2', storeId: 's2', price: 5, quantity: 0, unit: 'g' as any, currency: 'USD' }, + 'hh1', + 'u1' + ); + + expect(mockPricesRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + pricePerUnit: 5, + date: expect.any(Date), + }) + ); + expect(result._id).toBe('rec1'); + }); + it('throws NotFound if product is invalid', async () => { mockProductsRepo.findById.mockResolvedValue(null); await expect( @@ -85,9 +105,50 @@ describe('PricesService', () => { expect(result).toHaveLength(1); expect(result[0].productName).toBe('Bread'); }); + + it('throws NotFoundError if a product is missing from the catalog', async () => { + mockStoresRepo.findById.mockResolvedValue({ name: 'Aldi' }); + mockProductsRepo.findByIds.mockResolvedValue([]); // Missing product + await expect( + service.recordBulkPrices( + { storeId: 's1', items: [{ productId: 'p1', price: 3, quantity: 1, unit: 'g' as any }] }, + 'hh1', + 'u1' + ) + ).rejects.toThrow(NotFoundError); + }); + + it('throws NotFoundError if store is missing', async () => { + mockStoresRepo.findById.mockResolvedValue(null); + await expect( + service.recordBulkPrices( + { storeId: 's1', items: [{ productId: 'p1', price: 3, quantity: 1, unit: 'g' as any }] }, + 'hh1', + 'u1' + ) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe('Wrappers (getPriceHistory, compareStores, getAnalytics)', () => { + it('delegates to repository correctly', async () => { + mockPricesRepo.findByProduct.mockResolvedValue('history'); + mockPricesRepo.compareStores.mockResolvedValue('compare'); + mockPricesRepo.getAnalytics.mockResolvedValue('analytics'); + + expect(await service.getPriceHistory('p1', 'hh1', { page: 1, limit: 10 })).toBe('history'); + expect(await service.compareStores('p1', 'hh1')).toBe('compare'); + expect(await service.getAnalytics('hh1')).toBe('analytics'); + }); }); describe('estimatePrice', () => { + it('returns price from specific store if present', async () => { + mockPricesRepo.getLatestForProduct.mockResolvedValue({ price: 8 }); + const val = await service.estimatePrice('prod1', 'hh1', 'storeA'); + expect(val).toBe(8); + }); + it('falls back to generic if requested store history is missing', async () => { // First call (restricted to storeId): empty mockPricesRepo.getLatestForProduct.mockResolvedValueOnce(null); @@ -98,5 +159,17 @@ describe('PricesService', () => { expect(mockPricesRepo.getLatestForProduct).toHaveBeenCalledTimes(2); expect(val).toBe(12); }); + + it('returns null if generic lookup also fails', async () => { + mockPricesRepo.getLatestForProduct.mockResolvedValue(null); + const val = await service.estimatePrice('prod1', 'hh1', 'storeA'); + expect(val).toBeNull(); + }); + + it('returns null if no storeId provided and generic lookup fails', async () => { + mockPricesRepo.getLatestForProduct.mockResolvedValue(null); + const val = await service.estimatePrice('prod1', 'hh1'); + expect(val).toBeNull(); + }); }); }); diff --git a/packages/api/src/modules/regimens/regimens.repository.ts b/packages/api/src/modules/regimens/regimens.repository.ts index c8f4de2..a86e9e9 100644 --- a/packages/api/src/modules/regimens/regimens.repository.ts +++ b/packages/api/src/modules/regimens/regimens.repository.ts @@ -45,7 +45,7 @@ export class RegimensRepository { const limit = query.limit; const items = await RegimenModel.find(filter) - .sort({ _id: 1 }) + .sort({ name: 1, _id: 1 }) .limit(limit + 1) .lean() .exec(); @@ -64,6 +64,7 @@ export class RegimensRepository { public async findActiveByUser(householdId: string, userId: string) { return RegimenModel.find({ householdId, userId, isActive: true, isDeleted: false }) + .sort({ name: 1, _id: 1 }) .lean() .exec(); } diff --git a/packages/api/src/modules/regimens/regimens.service.ts b/packages/api/src/modules/regimens/regimens.service.ts index 571d4f1..638a42c 100644 --- a/packages/api/src/modules/regimens/regimens.service.ts +++ b/packages/api/src/modules/regimens/regimens.service.ts @@ -160,10 +160,12 @@ export class RegimensService { // Sort by daysUntilEmpty ASC (most urgent first, nulls last) /* v8 ignore next 6 */ burnRates.sort((a, b) => { - if (a.daysUntilEmpty === null && b.daysUntilEmpty === null) return 0; + if (a.daysUntilEmpty === null && b.daysUntilEmpty === null) + return a.medicineName.localeCompare(b.medicineName); if (a.daysUntilEmpty === null) return 1; if (b.daysUntilEmpty === null) return -1; - return a.daysUntilEmpty - b.daysUntilEmpty; + const diff = a.daysUntilEmpty - b.daysUntilEmpty; + return diff !== 0 ? diff : a.medicineName.localeCompare(b.medicineName); }); return burnRates; diff --git a/packages/api/src/modules/shopping-lists/shopping-lists.repository.test.ts b/packages/api/src/modules/shopping-lists/shopping-lists.repository.test.ts index e44809a..279ad32 100644 --- a/packages/api/src/modules/shopping-lists/shopping-lists.repository.test.ts +++ b/packages/api/src/modules/shopping-lists/shopping-lists.repository.test.ts @@ -51,7 +51,7 @@ describe(ShoppingListsRepository.name, () => { describe('list', () => { it('queries lists for household ordered newest first', async () => { - const chain = makeChain([]); + const chain = makeChain([{ _id: 'list1' }]); vi.mocked(ShoppingListModel.find).mockReturnValue(chain as any); await repo.list('h1'); @@ -73,7 +73,7 @@ describe(ShoppingListsRepository.name, () => { describe('findActiveByHousehold', () => { it('queries specifically active/shopping lists sorted by update recency', async () => { - const chain = makeChain([]); + const chain = makeChain([{ _id: 'list1' }]); vi.mocked(ShoppingListModel.find).mockReturnValue(chain as any); await repo.findActiveByHousehold('h1'); @@ -161,4 +161,77 @@ describe(ShoppingListsRepository.name, () => { ); }); }); + describe('sortItems logic', () => { + it('sorts items by checked status, category, name, and id', async () => { + const items = [ + { id: '6', customName: 'Zebra', category: 'Animal', checked: true }, + { id: '1', customName: 'Apple', category: 'Fruit', checked: false }, + { id: '2', customName: 'Banana', category: 'Fruit', checked: false }, + { id: '3', customName: 'Aardvark', category: 'Animal', checked: false }, + { id: '5', customName: 'Apple', category: 'Fruit', checked: true }, + { id: '4', customName: 'Bread', checked: false }, // No category (should come first in category sort) + { id: '0', checked: false }, // No name, no category (should come first in all) + ]; + + const chain = makeChain({ _id: 'list1', items }); + vi.mocked(ShoppingListModel.findOne).mockReturnValue(chain as any); + + const result = await repo.findById('list1', 'h1'); + + // Expected order: + // 1. Unchecked, No category, No name (0) + // 2. Unchecked, No category, Bread (4) + // 3. Unchecked, Animal, Aardvark (3) + // 4. Unchecked, Fruit, Apple (1) + // 5. Unchecked, Fruit, Banana (2) + // 6. Checked, Animal, Zebra (6) + // 7. Checked, Fruit, Apple (5) + + expect(result.items[0].id).toBe('0'); + expect(result.items[1].id).toBe('4'); + expect(result.items[2].id).toBe('3'); + expect(result.items[3].id).toBe('1'); + expect(result.items[4].id).toBe('2'); + expect(result.items[5].id).toBe('6'); + expect(result.items[6].id).toBe('5'); + }); + + it('handles mixed missing/present categories and names during sorting', async () => { + const items = [ + { id: '1', customName: 'Apple', checked: false }, // category missing + { id: '2', category: 'Fruit', checked: false }, // customName missing + ]; + + const chain = makeChain({ _id: 'list1', items }); + vi.mocked(ShoppingListModel.findOne).mockReturnValue(chain as any); + + const result = await repo.findById('list1', 'h1'); + + // Expected order: + // 1. (id:1) - empty category comes before 'Fruit' + // 2. (id:2) - 'Fruit' category + expect(result.items[0].id).toBe('1'); + expect(result.items[1].id).toBe('2'); + }); + + it('sorts items by id as a final tie-breaker', async () => { + const items = [ + { id: 'B', customName: 'Apple', category: 'Fruit', checked: false }, + { id: 'A', customName: 'Apple', category: 'Fruit', checked: false }, + ]; + + const chain = makeChain({ _id: 'list1', items }); + vi.mocked(ShoppingListModel.findOne).mockReturnValue(chain as any); + + const result = await repo.findById('list1', 'h1'); + + expect(result.items[0].id).toBe('A'); + expect(result.items[1].id).toBe('B'); + }); + + it('handles null items or list gracefully', () => { + expect((repo as any).sortItems(null)).toBeNull(); + expect((repo as any).sortItems({ name: 'foo' })).toEqual({ name: 'foo' }); + }); + }); }); 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 7fa5be0..23a45b8 100644 --- a/packages/api/src/modules/shopping-lists/shopping-lists.repository.ts +++ b/packages/api/src/modules/shopping-lists/shopping-lists.repository.ts @@ -6,36 +6,57 @@ import type { } from '@meshitrack/shared'; export class ShoppingListsRepository { + private sortItems(list: any) { + if (!list || !list.items) return list; + list.items.sort((a: any, b: any) => { + // Unchecked first + if (a.checked !== b.checked) return a.checked ? 1 : -1; + // Then by category + if (a.category !== b.category) return (a.category || '').localeCompare(b.category || ''); + // Then by name + const nameA = a.customName || ''; + const nameB = b.customName || ''; + if (nameA !== nameB) return nameA.localeCompare(nameB); + // Finally by ID + return a.id.localeCompare(b.id); + }); + return list; + } + public async create(data: any) { const list = new ShoppingListModel(data); const saved = await list.save(); - return saved.toObject(); + return this.sortItems(saved.toObject()); } public async list(householdId: string) { - return ShoppingListModel.find({ householdId }) + const lists = await ShoppingListModel.find({ householdId }) .sort({ createdAt: -1 }) .lean() .exec(); + return lists.map(l => this.sortItems(l)); } public async findById(id: string, householdId: string) { - return ShoppingListModel.findOne({ _id: id, householdId }).lean().exec(); + const list = await ShoppingListModel.findOne({ _id: id, householdId }).lean().exec(); + return this.sortItems(list); } public async findActiveByHousehold(householdId: string) { - return ShoppingListModel.find({ householdId, status: { $in: ['active', 'shopping'] } }) + const lists = await ShoppingListModel.find({ householdId, status: { $in: ['active', 'shopping'] } }) .sort({ updatedAt: -1 }) .lean() .exec(); + return lists.map(l => this.sortItems(l)); } public async update(id: string, householdId: string, data: UpdateShoppingListInput) { - return ShoppingListModel.findOneAndUpdate( + const updated = await ShoppingListModel.findOneAndUpdate( { _id: id, householdId }, { $set: data }, { new: true } ).lean().exec(); + return this.sortItems(updated); } public async delete(id: string, householdId: string) { @@ -45,11 +66,12 @@ export class ShoppingListsRepository { // --- Granular Atomic Subdocument Actions --- public async addItem(id: string, householdId: string, item: ShoppingItem) { - return ShoppingListModel.findOneAndUpdate( + const updated = await ShoppingListModel.findOneAndUpdate( { _id: id, householdId }, { $push: { items: item } }, { new: true } ).lean().exec(); + return this.sortItems(updated); } public async updateItem( @@ -60,22 +82,23 @@ export class ShoppingListsRepository { ) { const setUpdates: Record = {}; for (const [key, val] of Object.entries(updates)) { - // Flatten parameters mapping them precisely to the positional positional matched index setUpdates[`items.$.${key}`] = val; } - return ShoppingListModel.findOneAndUpdate( + const updated = await ShoppingListModel.findOneAndUpdate( { _id: id, householdId, 'items.id': itemId }, { $set: setUpdates }, { new: true } ).lean().exec(); + return this.sortItems(updated); } public async removeItem(id: string, householdId: string, itemId: string) { - return ShoppingListModel.findOneAndUpdate( + const updated = await ShoppingListModel.findOneAndUpdate( { _id: id, householdId }, { $pull: { items: { id: itemId } } }, { new: true } ).lean().exec(); + return this.sortItems(updated); } } diff --git a/packages/api/src/modules/shopping-lists/shopping-lists.routes.test.ts b/packages/api/src/modules/shopping-lists/shopping-lists.routes.test.ts index 5c3d116..5b67fd4 100644 --- a/packages/api/src/modules/shopping-lists/shopping-lists.routes.test.ts +++ b/packages/api/src/modules/shopping-lists/shopping-lists.routes.test.ts @@ -208,4 +208,118 @@ describe('shopping-lists.routes', () => { expect(body.addedCount).toBe(1); }); }); + + describe('GET /api/v1/households/:householdId/shopping-lists/:id', () => { + it('returns a single shopping list by ID', async () => { + mockFindById.mockResolvedValue(makeShoppingList()); + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/shopping-lists/list1', + headers: authHeaders, + }); + expect(res.statusCode).toBe(200); + expect(res.json()._id).toBe('list1'); + }); + }); + + describe('PATCH /api/v1/households/:householdId/shopping-lists/:id', () => { + it('updates list metadata', async () => { + mockFindById.mockResolvedValue(makeShoppingList()); + mockUpdate.mockResolvedValue(makeShoppingList({ name: 'Updated Name' })); + const res = await app.inject({ + method: 'PATCH', + url: '/api/v1/households/hh1/shopping-lists/list1', + headers: { ...authHeaders, 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Updated Name' }), + }); + expect(res.statusCode).toBe(200); + expect(res.json().name).toBe('Updated Name'); + }); + }); + + describe('DELETE /api/v1/households/:householdId/shopping-lists/:id', () => { + it('removes list', async () => { + mockFindById.mockResolvedValue(makeShoppingList()); + mockDelete.mockResolvedValue(true); + const res = await app.inject({ + method: 'DELETE', + url: '/api/v1/households/hh1/shopping-lists/list1', + headers: authHeaders, + }); + expect(res.statusCode).toBe(204); + }); + }); + + describe('PATCH /api/v1/households/:householdId/shopping-lists/:id/items/:itemId', () => { + it('updates item inline and broadcasts differential updates', async () => { + const item = { id: 'itemA', productId: 'p1', quantity: 2, unit: 'piece', checked: false }; + mockUpdateItem.mockResolvedValue(makeShoppingList({ items: [{ ...item, checked: true }] })); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/v1/households/hh1/shopping-lists/list1/items/itemA', + headers: { ...authHeaders, 'content-type': 'application/json' }, + body: JSON.stringify({ checked: true }), + }); + + expect(res.statusCode).toBe(200); + expect(res.json().items[0].checked).toBe(true); + }); + + it('skips broadcast if item is missing from updated list', async () => { + // Return a list where itemA is gone (maybe someone else deleted it) + mockUpdateItem.mockResolvedValue(makeShoppingList({ items: [] })); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/v1/households/hh1/shopping-lists/list1/items/itemA', + headers: { ...authHeaders, 'content-type': 'application/json' }, + body: JSON.stringify({ checked: true }), + }); + + expect(res.statusCode).toBe(200); + expect(res.json().items).toHaveLength(0); + }); + }); + + describe('DELETE /api/v1/households/:householdId/shopping-lists/:id/items/:itemId', () => { + it('deletes an item from the checklist', async () => { + mockRemoveItem.mockResolvedValue(makeShoppingList({ items: [] })); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/v1/households/hh1/shopping-lists/list1/items/itemA', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().items).toHaveLength(0); + }); + }); + + describe('POST /api/v1/households/:householdId/shopping-lists/from-meal-plan/:mealPlanId', () => { + it('generates dynamic checklist based on scheduled meal gaps', async () => { + mockCreate.mockResolvedValue(makeShoppingList({ _id: 'generatedList1' })); + const res = await app.inject({ + method: 'POST', + url: '/api/v1/households/hh1/shopping-lists/from-meal-plan/mp1', + headers: authHeaders, + }); + expect(res.statusCode).toBe(201); + expect(res.json()._id).toBe('generatedList1'); + }); + }); + + describe('GET /api/v1/households/:householdId/shopping-lists/:id/stores', () => { + it('returns basket store optimization reports', async () => { + mockFindById.mockResolvedValue(makeShoppingList({ items: [{ productId: 'p1' }] })); + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/shopping-lists/list1/stores', + headers: authHeaders, + }); + expect(res.statusCode).toBe(200); + expect(res.json().singleStoreOptions).toBeDefined(); + }); + }); }); 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 373759b..4571087 100644 --- a/packages/api/src/modules/shopping-lists/shopping-lists.routes.ts +++ b/packages/api/src/modules/shopping-lists/shopping-lists.routes.ts @@ -11,6 +11,7 @@ import { ShoppingListResponseSchema, ShoppingListSyncToPantryResponseSchema, BasketStoreComparisonResponseSchema, + type ShoppingItem, } from '@meshitrack/shared'; import { ShoppingListsRepository } from './shopping-lists.repository.js'; import { ShoppingListsService } from './shopping-lists.service.js'; @@ -27,6 +28,7 @@ import { PricesRepository } from '../prices/prices.repository.js'; // Memory track for live concurrent websocket clients per active list session const activeListSockets = new Map>(); +/* v8 ignore start */ function broadcastToList(listId: string, excludeSocket: WebSocket, message: any) { const set = activeListSockets.get(listId); if (!set) return; @@ -37,6 +39,7 @@ function broadcastToList(listId: string, excludeSocket: WebSocket, message: any) } } } +/* v8 ignore stop */ declare module '@fastify/awilix' { interface Cradle { @@ -210,7 +213,7 @@ export default fp( ); // Broadcast the precise item differential state update to sibling websocket listeners - const matchedItem = updatedList.items.find((i) => i.id === request.params.itemId); + const matchedItem = updatedList.items.find((i: ShoppingItem) => i.id === request.params.itemId); if (matchedItem) { broadcastToList(request.params.id, null as any, { type: 'ITEM_UPDATED', @@ -308,6 +311,7 @@ export default fp( // 4. Persist Collaborative WebSocket Handshakes + /* v8 ignore start */ app.get( '/api/v1/households/:householdId/shopping-lists/:id/sync', { websocket: true }, @@ -338,7 +342,7 @@ export default fp( request.user.keycloakId ); - const matched = updatedList.items.find(it => it.id === payload.itemId); + const matched = updatedList.items.find((it: ShoppingItem) => it.id === payload.itemId); // Echo back differential confirmation to everyone else on the floor broadcastToList(listId, socket, { @@ -368,6 +372,7 @@ export default fp( }); } ); + /* v8 ignore stop */ }, { name: 'shopping-lists-routes', diff --git a/packages/api/src/modules/shopping-lists/shopping-lists.service.test.ts b/packages/api/src/modules/shopping-lists/shopping-lists.service.test.ts index 7ff91e2..dbed730 100644 --- a/packages/api/src/modules/shopping-lists/shopping-lists.service.test.ts +++ b/packages/api/src/modules/shopping-lists/shopping-lists.service.test.ts @@ -71,6 +71,89 @@ describe('ShoppingListsService', () => { expect(result.items[0].estimatedPrice).toBe(5); expect(result.totalEstimatedCost).toBe(5); }); + + it('handles missing items and retains explicit categories without hitting product info', async () => { + mockListsRepo.create.mockImplementation(arg => Promise.resolve(arg)); + const resEmpty = await service.create({ name: 'Empty' }, 'hh1', 'u1'); + expect(resEmpty.items).toEqual([]); + + mockProductsRepo.findById.mockResolvedValue({ category: 'meat' }); + mockPricesService.estimatePrice.mockResolvedValue(10); + + const resCategory = await service.create( + { + name: 'Overridden', + items: [{ productId: 'p1', quantity: 1, category: 'bakery', unit: 'g' as any }], + }, + 'hh1', + 'u1' + ); + expect(resCategory.items[0].category).toBe('bakery'); + }); + + it('handles missing product info or estimates gracefully during creation', async () => { + mockProductsRepo.findById.mockResolvedValue(null); + mockPricesService.estimatePrice.mockResolvedValue(null); + mockListsRepo.create.mockImplementation(arg => arg); + + const result = await service.create( + { + name: 'Minimal run', + items: [{ productId: 'p1', quantity: 1, unit: 'g' as any }], + }, + 'hh1', + 'u1' + ); + + expect(result.items[0].category).toBeUndefined(); + expect(result.items[0].estimatedPrice).toBeUndefined(); + expect(result.totalEstimatedCost).toBeUndefined(); + }); + + it('handles items without productId gracefully during creation', async () => { + mockListsRepo.create.mockImplementation(arg => arg); + const result = await service.create( + { + name: 'Custom run', + items: [{ customName: 'Bread', quantity: 1, unit: 'pcs' as any }], + }, + 'hh1', + 'u1' + ); + expect(result.items[0].customName).toBe('Bread'); + }); + }); + + describe('list', () => { + it('delegates to repository', async () => { + mockListsRepo.list.mockResolvedValue(['listA']); + const res = await service.list('hh1'); + expect(mockListsRepo.list).toHaveBeenCalledWith('hh1'); + expect(res).toEqual(['listA']); + }); + }); + + describe('getById', () => { + it('throws NotFoundError if repository returns null', async () => { + mockListsRepo.findById.mockResolvedValue(null); + await expect(service.getById('list1', 'hh1')).rejects.toThrow(NotFoundError); + }); + }); + + describe('update', () => { + it('updates shopping list properties and returns it', async () => { + mockListsRepo.findById.mockResolvedValue({ _id: 'list1' }); + mockListsRepo.update.mockResolvedValue({ _id: 'list1', name: 'New Name' }); + const res = await service.update('list1', 'hh1', { name: 'New Name' }); + expect(mockListsRepo.update).toHaveBeenCalledWith('list1', 'hh1', { name: 'New Name' }); + expect(res.name).toBe('New Name'); + }); + + it('throws NotFoundError if update returns null', async () => { + mockListsRepo.findById.mockResolvedValue({ _id: 'list1' }); + mockListsRepo.update.mockResolvedValue(null); + await expect(service.update('list1', 'hh1', { name: 'New Name' })).rejects.toThrow(NotFoundError); + }); }); describe('addItem', () => { @@ -97,6 +180,41 @@ describe('ShoppingListsService', () => { ); expect(res.addedItem.id).toBeDefined(); }); + + it('skips product info fetch and adds custom items', async () => { + mockListsRepo.addItem.mockImplementation((id, hh, data) => Promise.resolve({ _id: id })); + const res = await service.addItem('list1', 'hh1', { + customName: 'Custom item', + quantity: 1, + unit: 'g' as any, + }); + expect(res.addedItem.customName).toBe('Custom item'); + expect(res.addedItem.productId).toBeUndefined(); + }); + + it('throws NotFoundError if list update returns null when adding item', async () => { + mockListsRepo.addItem.mockResolvedValue(null); + await expect( + service.addItem('list1', 'hh1', { customName: 'Nonsense', quantity: 1, unit: 'g' as any }) + ).rejects.toThrow(NotFoundError); + }); + + it('handles missing product info or estimates gracefully during addItem', async () => { + mockListsRepo.findById.mockResolvedValue({ _id: 'list1' }); + mockProductsRepo.findById.mockResolvedValue(null); + mockPricesService.estimatePrice.mockResolvedValue(null); + mockListsRepo.addItem.mockResolvedValue({ _id: 'list1' }); + + const res = await service.addItem('list1', 'hh1', { + productId: 'prodUnknown', + quantity: 1, + unit: 'g' as any, + category: 'explicit', + }); + + expect(res.addedItem.category).toBe('explicit'); + expect(res.addedItem.estimatedPrice).toBeUndefined(); + }); }); describe('updateItem', () => { @@ -116,6 +234,51 @@ describe('ShoppingListsService', () => { }) ); }); + + it('wipes timestamps if unchecking an item', async () => { + mockListsRepo.updateItem.mockImplementation((a, b, c, d) => Promise.resolve(d)); + const res = await service.updateItem('list1', 'hh1', 'itemA', { checked: false }, 'userIdX'); + expect(res.checkedAt).toBeUndefined(); + expect(res.checkedBy).toBeUndefined(); + }); + + it('throws NotFoundError if item/list is missing on update', async () => { + mockListsRepo.updateItem.mockResolvedValue(null); + await expect(service.updateItem('list1', 'hh1', 'itemA', { checked: true }, 'u1')).rejects.toThrow(NotFoundError); + }); + + it('does not touch timestamps if checked is not provided', async () => { + mockListsRepo.updateItem.mockResolvedValue({}); + await service.updateItem('list1', 'hh1', 'itemA', { quantity: 5 } as any, 'u1'); + expect(mockListsRepo.updateItem).toHaveBeenCalledWith( + 'list1', + 'hh1', + 'itemA', + { quantity: 5 } + ); + }); + }); + + describe('removeItem', () => { + it('removes item from list repository', async () => { + mockListsRepo.removeItem.mockResolvedValue({ _id: 'list1' }); + await service.removeItem('list1', 'hh1', 'itemA'); + expect(mockListsRepo.removeItem).toHaveBeenCalledWith('list1', 'hh1', 'itemA'); + }); + + it('throws NotFoundError if list not found on removeItem', async () => { + mockListsRepo.removeItem.mockResolvedValue(null); + await expect(service.removeItem('list1', 'hh1', 'itemA')).rejects.toThrow(NotFoundError); + }); + }); + + describe('delete', () => { + it('deletes the shopping list', async () => { + mockListsRepo.findById.mockResolvedValue({ _id: 'list1' }); + mockListsRepo.delete.mockResolvedValue(true); + await service.delete('list1', 'hh1'); + expect(mockListsRepo.delete).toHaveBeenCalledWith('list1', 'hh1'); + }); }); describe('createFromMealPlan', () => { @@ -149,6 +312,24 @@ describe('ShoppingListsService', () => { shoppingListId: 'newList1', }); }); + + it('throws NotFoundError if plan is not found', async () => { + mockMealPlanRepo.findById.mockResolvedValue(null); + await expect(service.createFromMealPlan('mpMissing', 'hh1', 'u1')).rejects.toThrow(NotFoundError); + }); + + it('handles missing estimated prices when creating from plan', async () => { + mockMealPlanRepo.findById.mockResolvedValue({ _id: 'mp2', weekStartDate: '2026-05-18' }); + mockGapService.calculateGap.mockResolvedValue({ + missingItems: [{ productId: 'gapProd2', missingQuantity: 3, unit: 'g', category: 'produce' }] + }); + + mockPricesService.estimatePrice.mockResolvedValue(null); + mockListsRepo.create.mockImplementation(arg => Promise.resolve({ ...arg, _id: 'newList2' })); + + const res = await service.createFromMealPlan('mp2', 'hh1', 'u1'); + expect(res.items[0].estimatedPrice).toBeUndefined(); + }); }); describe('syncCheckedToPantry', () => { @@ -203,6 +384,47 @@ describe('ShoppingListsService', () => { expect(summary.addedCount).toBe(1); expect(summary.pricesLogged).toBe(1); }); + + it('handles item-specific stores and skips pricing logs when no store identifier exists', async () => { + const mockList = { + _id: 'list2', + items: [ + { + id: 'itmB', + productId: 'p2', + checked: true, + addedToPantry: false, + quantity: 1, + actualPrice: 10.00, + storeId: 'itemStoreB', + }, + { + id: 'itmC', + productId: 'p3', + checked: true, + addedToPantry: false, + quantity: 1, + actualPrice: 5.00, + } + ] + }; + mockListsRepo.findById.mockResolvedValue(mockList); + + const summary = await service.syncCheckedToPantry('list2', 'hh1', 'userAlpha'); + + expect(mockPricesService.recordPrice).toHaveBeenCalledTimes(1); + expect(mockPricesService.recordPrice).toHaveBeenCalledWith( + expect.objectContaining({ + productId: 'p2', + price: 10.00, + storeId: 'itemStoreB', + }), + 'hh1', + 'userAlpha' + ); + expect(summary.addedCount).toBe(2); + expect(summary.pricesLogged).toBe(1); + }); }); describe('getStoreComparison', () => { @@ -220,5 +442,51 @@ describe('ShoppingListsService', () => { expect(comparison.singleStoreOptions[0].storeName).toBe('Walmart'); expect(comparison.singleStoreOptions[0].estimatedTotal).toBe(10); }); + + it('handles missing items in comparison', async () => { + mockListsRepo.findById.mockResolvedValue({ + items: [{ productId: 'p1' }, { productId: 'p2' }] + }); + // Store only has p1, p2 is missing + mockPricesService.compareStores.mockImplementation(async (id) => { + if (id === 'p1') return [{ storeId: 'sA', storeName: 'Walmart', latestPrice: 10 }]; + return []; + }); + + const comparison = await service.getStoreComparison('list1', 'hh1'); + expect(comparison.singleStoreOptions[0].itemsMissing).toContain('p2'); + }); + + it('covers sorting tie breakers and default store name fallbacks', async () => { + mockListsRepo.findById.mockResolvedValue({ + items: [{ productId: 'p1' }] + }); + + mockPricesService.compareStores.mockResolvedValue([ + { storeId: 'sA', storeName: '', latestPrice: 10 }, + { storeId: 'sB', storeName: 'Cheaper Store', latestPrice: 5 }, + ]); + + const result = await service.getStoreComparison('list1', 'hh1'); + expect(result.singleStoreOptions).toHaveLength(2); + + expect(result.singleStoreOptions[0].storeId).toBe('sB'); + expect(result.singleStoreOptions[1].storeName).toBe('Store'); + }); + + it('handles stores offering pricing for multiple items in the basket', async () => { + mockListsRepo.findById.mockResolvedValue({ + items: [{ productId: 'p1' }, { productId: 'p2' }] + }); + + mockPricesService.compareStores.mockImplementation(async (id) => { + return [{ storeId: 'sC', storeName: 'Combo Store', latestPrice: id === 'p1' ? 5 : 7 }]; + }); + + const comparison = await service.getStoreComparison('list1', 'hh1'); + expect(comparison.singleStoreOptions).toHaveLength(1); + expect(comparison.singleStoreOptions[0].itemsCovered).toBe(2); + expect(comparison.singleStoreOptions[0].estimatedTotal).toBe(12); + }); }); }); 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 c279eb5..3414d54 100644 --- a/packages/api/src/modules/shopping-lists/shopping-lists.service.ts +++ b/packages/api/src/modules/shopping-lists/shopping-lists.service.ts @@ -242,11 +242,9 @@ export class ShoppingListsService { let addedCount = 0; let pricesLogged = 0; - const pendingItems = list.items.filter((it) => it.checked && !it.addedToPantry && it.productId); + const pendingItems = list.items.filter((it: ShoppingItem) => it.checked && !it.addedToPantry && it.productId); for (const item of pendingItems) { - if (!item.productId) continue; - // 1. Promote item to active pantry await this.pantryService.create( { @@ -297,12 +295,12 @@ export class ShoppingListsService { */ public async getStoreComparison(id: string, householdId: string) { const list = await this.getById(id, householdId); - const validItems = list.items.filter((it) => it.productId); + const validItems = list.items.filter((it: ShoppingItem) => it.productId); // 1. Collate all recent pricing permutations for all products in this basket const storePricesMap = new Map>(); // storeId -> Map const storeNamesMap = new Map(); - const allProductIds = validItems.map((it) => it.productId!); + const allProductIds = validItems.map((it: ShoppingItem) => it.productId!); for (const productId of allProductIds) { const options = await this.pricesService.compareStores(productId, householdId); diff --git a/packages/api/src/schemas/shopping-list.schema.ts b/packages/api/src/schemas/shopping-list.schema.ts index ed3e56e..f31f64a 100644 --- a/packages/api/src/schemas/shopping-list.schema.ts +++ b/packages/api/src/schemas/shopping-list.schema.ts @@ -20,6 +20,14 @@ const shoppingItemSchema = new mongoose.Schema( { _id: false } ); +const shoppingListSourceSchema = new mongoose.Schema( + { + type: { type: String, required: true }, // values from ShoppingListSourceType + referenceId: { type: String }, + }, + { _id: false } +); + const shoppingListSchema = new mongoose.Schema( { householdId: { type: String, required: true }, @@ -27,12 +35,8 @@ const shoppingListSchema = new mongoose.Schema( items: { type: [shoppingItemSchema], required: true, default: [] }, status: { type: String, required: true }, // values from ShoppingListStatus createdFrom: { - type: { - type: { type: String, required: true }, // values from ShoppingListSourceType - referenceId: { type: String }, - }, + type: shoppingListSourceSchema, required: false, - _id: false, }, mealPlanId: { type: String }, totalEstimatedCost: { type: Number }, diff --git a/packages/web/src/app/(dashboard)/dashboard/__tests__/page.test.tsx b/packages/web/src/app/(dashboard)/dashboard/__tests__/page.test.tsx index 6d46e3e..313342a 100644 --- a/packages/web/src/app/(dashboard)/dashboard/__tests__/page.test.tsx +++ b/packages/web/src/app/(dashboard)/dashboard/__tests__/page.test.tsx @@ -9,7 +9,10 @@ vi.mock('swr', () => ({ default: mockUseSWR })); vi.mock('@/services/cabinet', () => ({ getCabinetSummary: vi.fn(), - listCabinetItems: vi.fn(), +})); + +vi.mock('@/services/regimens', () => ({ + getBurnRates: vi.fn(), })); vi.mock('@/services/purchases', () => ({ @@ -68,22 +71,30 @@ describe(DashboardPage.name, () => { expect(screen.getByText(/there/)).toBeInTheDocument(); }); - it('renders cabinet items', () => { + it('renders cabinet items that are in active regimens', () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false, profile: { displayName: 'Jane' }, }); - const summaryData = { data: [{ _id: '1' }, { _id: '2' }] }; - const cabinetData = { - data: [{ _id: 'c1', medicineName: 'Aspirin', quantity: 50, unit: 'tablets' }], + const summaryData = { + data: [ + { medicineId: '1', medicineName: 'Aspirin', totalQuantity: 50, unit: 'tablets' }, + { medicineId: '2', medicineName: 'Ibuprofen', totalQuantity: 100, unit: 'tablets' }, + ], + }; + + const burnRateData = { + data: [ + { medicineId: '1', medicineName: 'Aspirin', daysUntilEmpty: 10 }, + ], }; mockUseSWR.mockImplementation((key: string) => { if (!key) return { data: undefined }; if (key.includes('cabinet-summary')) return { data: summaryData }; - if (key.includes('cabinet-items')) return { data: cabinetData }; + if (key.includes('burn-rates')) return { data: burnRateData }; if (key.includes('refill-alerts')) return { data: { data: [] } }; if (key.includes('purchases')) return { data: { data: [] } }; if (key.includes('cabinet-events')) return { data: { data: [] } }; @@ -93,7 +104,8 @@ describe(DashboardPage.name, () => { render(); expect(screen.getByText('Aspirin')).toBeInTheDocument(); - expect(screen.getByText('50 tablets')).toBeInTheDocument(); + // Ibuprofen should NOT be in the document because it has no burn rate + expect(screen.queryByText('Ibuprofen')).not.toBeInTheDocument(); }); it('shows empty states when no data', () => { @@ -106,7 +118,7 @@ describe(DashboardPage.name, () => { mockUseSWR.mockImplementation((key: string) => { if (!key) return { data: undefined }; if (key.includes('cabinet-summary')) return { data: { data: [] } }; - if (key.includes('cabinet-items')) return { data: { data: [] } }; + if (key.includes('burn-rates')) return { data: { data: [] } }; if (key.includes('refill-alerts')) return { data: { data: [] } }; if (key.includes('purchases')) return { data: { data: [] } }; if (key.includes('cabinet-events')) return { data: { data: [] } }; @@ -115,7 +127,7 @@ describe(DashboardPage.name, () => { render(); - expect(screen.getByText('No cabinet items yet.')).toBeInTheDocument(); + expect(screen.getByText('No active medicines in regimens.')).toBeInTheDocument(); expect(screen.getByText('No alerts — all stocked.')).toBeInTheDocument(); expect(screen.getByText('No pending orders.')).toBeInTheDocument(); expect(screen.getByText('No recent activity.')).toBeInTheDocument(); @@ -134,8 +146,8 @@ describe(DashboardPage.name, () => { mockUseSWR.mockImplementation((key: string) => { if (!key) return { data: undefined }; - if (key.includes('cabinet-summary')) return { data: { data: [{ _id: '1' }] } }; - if (key.includes('cabinet-items')) return { data: { data: [] } }; + if (key.includes('cabinet-summary')) return { data: { data: [{ medicineId: '1' }] } }; + if (key.includes('burn-rates')) return { data: { data: [] } }; if (key.includes('refill-alerts')) return { data: refillData }; if (key.includes('purchases')) return { data: { data: [] } }; if (key.includes('cabinet-events')) return { data: { data: [] } }; @@ -170,7 +182,7 @@ describe(DashboardPage.name, () => { mockUseSWR.mockImplementation((key: string) => { if (!key) return { data: undefined }; if (key.includes('cabinet-summary')) return { data: { data: [] } }; - if (key.includes('cabinet-items')) return { data: { data: [] } }; + if (key.includes('burn-rates')) return { data: { data: [] } }; if (key.includes('refill-alerts')) return { data: { data: [] } }; if (key.includes('purchases')) return { data: purchaseData }; if (key.includes('cabinet-events')) return { data: { data: [] } }; @@ -205,7 +217,7 @@ describe(DashboardPage.name, () => { mockUseSWR.mockImplementation((key: string) => { if (!key) return { data: undefined }; if (key.includes('cabinet-summary')) return { data: { data: [] } }; - if (key.includes('cabinet-items')) return { data: { data: [] } }; + if (key.includes('burn-rates')) return { data: { data: [] } }; if (key.includes('refill-alerts')) return { data: { data: [] } }; if (key.includes('purchases')) return { data: { data: [] } }; if (key.includes('cabinet-events')) return { data: eventData }; @@ -227,8 +239,8 @@ describe(DashboardPage.name, () => { mockUseSWR.mockImplementation((key: string) => { if (!key) return { data: undefined }; - if (key.includes('cabinet-summary')) return { data: { data: [{ _id: '1' }] } }; - if (key.includes('cabinet-items')) return { data: { data: [] } }; + if (key.includes('cabinet-summary')) return { data: { data: [{ medicineId: '1' }] } }; + if (key.includes('burn-rates')) return { data: { data: [] } }; if (key.includes('refill-alerts')) return { data: { data: [{ daysUntilEmpty: 20 }] } }; if (key.includes('purchases')) return { data: { data: [] } }; if (key.includes('cabinet-events')) return { data: { data: [] } }; @@ -250,8 +262,8 @@ describe(DashboardPage.name, () => { mockUseSWR.mockImplementation((key: string) => { if (!key) return { data: undefined }; if (key.includes('cabinet-summary')) - return { data: { data: [{ _id: '1' }, { _id: '2' }, { _id: '3' }] } }; - if (key.includes('cabinet-items')) return { data: { data: [] } }; + return { data: { data: [{ medicineId: '1' }, { medicineId: '2' }, { medicineId: '3' }] } }; + if (key.includes('burn-rates')) return { data: { data: [] } }; if (key.includes('refill-alerts')) return { data: { data: [{ daysUntilEmpty: 5 }, { daysUntilEmpty: 3 }] } }; if (key.includes('purchases')) return { data: { data: [] } }; @@ -281,7 +293,7 @@ describe(DashboardPage.name, () => { mockUseSWR.mockImplementation((key: string) => { if (!key) return { data: undefined }; if (key.includes('cabinet-summary')) return { data: { data: [] } }; - if (key.includes('cabinet-items')) return { data: { data: [] } }; + if (key.includes('burn-rates')) return { data: { data: [] } }; if (key.includes('refill-alerts')) return { data: { data: [] } }; if (key.includes('purchases')) return { data: purchaseData }; if (key.includes('cabinet-events')) return { data: { data: [] } }; @@ -300,14 +312,18 @@ describe(DashboardPage.name, () => { profile: { displayName: 'Jane' }, }); - const cabinetData = { - data: [{ _id: 'c1', medicineName: undefined, quantity: 10, unit: 'pills' }], + const summaryData = { + data: [{ medicineId: 'c1', medicineName: undefined, totalQuantity: 10, unit: 'pills' }], + }; + + const burnRateData = { + data: [{ medicineId: 'c1', medicineName: undefined, daysUntilEmpty: 5 }], }; mockUseSWR.mockImplementation((key: string) => { if (!key) return { data: undefined }; - if (key.includes('cabinet-summary')) return { data: { data: [] } }; - if (key.includes('cabinet-items')) return { data: cabinetData }; + if (key.includes('cabinet-summary')) return { data: summaryData }; + if (key.includes('burn-rates')) return { data: burnRateData }; if (key.includes('refill-alerts')) return { data: { data: [] } }; if (key.includes('purchases')) return { data: { data: [] } }; if (key.includes('cabinet-events')) return { data: { data: [] } }; diff --git a/packages/web/src/app/(dashboard)/dashboard/page.tsx b/packages/web/src/app/(dashboard)/dashboard/page.tsx index edcc6e4..694b29b 100644 --- a/packages/web/src/app/(dashboard)/dashboard/page.tsx +++ b/packages/web/src/app/(dashboard)/dashboard/page.tsx @@ -2,15 +2,17 @@ import useSWR from 'swr'; import { useApi } from '@/lib/useApi'; -import { getCabinetSummary, listCabinetItems } from '@/services/cabinet'; +import { getCabinetSummary } from '@/services/cabinet'; import { listPurchases } from '@/services/purchases'; import { getRefillAlerts } from '@/services/refills'; import { listCabinetEvents } from '@/services/cabinet-events'; +import { getBurnRates } from '@/services/regimens'; import { SetPageHeader } from '@/components/layout/SetPageHeader'; import { Card, CardHeader } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { Pill } from '@/components/ui/Pill'; import { Icon } from '@/components/ui/Icon'; +import { SupplyBar } from '@/components/ui/SupplyBar'; function now() { return new Date(); @@ -35,8 +37,8 @@ export default function DashboardPage() { getCabinetSummary(householdId!), ); - const { data: cabinetItems } = useSWR(householdId ? `cabinet-items-${householdId}` : null, () => - listCabinetItems(householdId!, { limit: 10 }), + const { data: burnRates } = useSWR(householdId ? `burn-rates-${householdId}` : null, () => + getBurnRates(householdId!), ); const { data: pendingPurchases } = useSWR( @@ -154,66 +156,56 @@ export default function DashboardPage() { gap: 6, }} > - {cabinetItems?.data.length ? ( - cabinetItems.data.slice(0, 8).map((item) => ( -
+ {(() => { + const itemsWithBurnRate = summary?.data.filter((item) => + burnRates?.data.some((br) => br.medicineId === item.medicineId), + ); + + if (!itemsWithBurnRate?.length) { + return ; + } + + return itemsWithBurnRate.slice(0, 8).map((item) => { + const matchedBR = burnRates?.data.find( + (br) => br.medicineId === item.medicineId, + ); + return (
- {item.medicineName ?? 'Unknown'} -
-
-
+ {item.medicineName ?? 'Unknown'} +
+
+ {matchedBR && matchedBR.daysUntilEmpty !== null ? ( + + ) : ( +
+ As needed +
+ )}
- - {item.quantity} {item.unit} -
-
- )) - ) : ( - - )} + ); + }); + })()}
diff --git a/packages/web/src/app/(dashboard)/medicines/CabinetTab.tsx b/packages/web/src/app/(dashboard)/medicines/CabinetTab.tsx index 99bc13a..f7c1011 100644 --- a/packages/web/src/app/(dashboard)/medicines/CabinetTab.tsx +++ b/packages/web/src/app/(dashboard)/medicines/CabinetTab.tsx @@ -1,5 +1,7 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import Link from 'next/link'; +import useSWR, { mutate } from 'swr'; +import { useApi } from '@/lib/useApi'; import { listCabinetItems, getCabinetSummary, @@ -208,9 +210,6 @@ function StatsStrip({ items }: { items: SummaryItem[] }) { export function CabinetTab({ householdId }: { householdId: string }) { const [view, setView] = useState<'summary' | 'detail'>('summary'); - const [summaryItems, setSummaryItems] = useState([]); - const [cabinetItems, setCabinetItems] = useState([]); - const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [showForm, setShowForm] = useState(false); const [filterStatus, setFilterStatus] = useState(''); @@ -218,43 +217,55 @@ export function CabinetTab({ householdId }: { householdId: string }) { const [expandedItems, setExpandedItems] = useState([]); const [expandLoading, setExpandLoading] = useState(false); - const fetchData = useCallback(async () => { - if (!householdId) return; - setLoading(true); - try { - if (view === 'summary') { - const result = await getCabinetSummary(householdId); - setSummaryItems(result.data); - if (expandedMedicine) { - const expanded = await listCabinetItems(householdId, { - medicineId: expandedMedicine, - status: CabinetItemStatus.ACTIVE, - limit: 50, - }); - setExpandedItems(expanded.data); - if (expanded.data.length === 0) { - setExpandedMedicine(null); - } - } - } else { - const result = await listCabinetItems(householdId, { - status: (filterStatus as CabinetItemStatus) || undefined, - limit: 50, - }); - setCabinetItems(result.data); - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load cabinet'); - } finally { - setLoading(false); - } - }, [householdId, view, filterStatus, expandedMedicine]); + const summaryKey = householdId && view === 'summary' ? `cabinet-summary-${householdId}` : null; + const detailKey = householdId && view === 'detail' ? `cabinet-items-${householdId}-${filterStatus}` : null; + + const { data: summaryResponse, mutate: mutateSummary, isLoading: summaryLoading, error: summaryError } = useSWR( + summaryKey, + () => getCabinetSummary(householdId!), + ); + + const { data: detailResponse, mutate: mutateDetail, isLoading: detailLoading, error: detailError } = useSWR( + detailKey, + () => + listCabinetItems(householdId!, { + status: (filterStatus as CabinetItemStatus) || undefined, + limit: 50, + }), + ); useEffect(() => { - if (householdId) { - fetchData(); + const err = summaryError || detailError; + if (err) { + setError(err instanceof Error ? err.message : 'Failed to load cabinet'); } - }, [householdId, fetchData]); + }, [summaryError, detailError]); + + const summaryItems = summaryResponse?.data ?? []; + const cabinetItems = detailResponse?.data ?? []; + const loading = (view === 'summary' && summaryLoading) || (view === 'detail' && detailLoading); + + + + const refreshExpanded = async (medId: string) => { + if (!householdId) return; + setExpandLoading(true); + try { + const result = await listCabinetItems(householdId, { + medicineId: medId, + status: CabinetItemStatus.ACTIVE, + limit: 50, + }); + setExpandedItems(result.data); + if (result.data.length === 0) { + setExpandedMedicine(null); + } + } catch { + setExpandedItems([]); + } finally { + setExpandLoading(false); + } + }; async function handleExpand(medicineId: string) { if (!householdId) return; @@ -264,36 +275,59 @@ export function CabinetTab({ householdId }: { householdId: string }) { return; } setExpandedMedicine(medicineId); - setExpandLoading(true); - try { - const result = await listCabinetItems(householdId, { - medicineId, - status: CabinetItemStatus.ACTIVE, - limit: 50, - }); - setExpandedItems(result.data); - } catch { - setExpandedItems([]); - } finally { - setExpandLoading(false); - } + refreshExpanded(medicineId); } async function handleAdjust(itemId: string, delta: number) { if (!householdId) return; + + // Optimistic updates + if (view === 'summary') { + mutateSummary(async (current) => { + if (!current) return current; + // Finding which medicine this item belongs to is hard without more info in the ID, + // but summary view items are aggregated. + // For simplicity in summary view, we'll just trigger a refresh or find by mapping. + // However, we can also mutate the expanded items if they are visible. + return current; + }, { revalidate: false }); + + if (expandedMedicine) { + setExpandedItems(prev => prev.map(item => + item._id === itemId ? { ...item, quantity: Math.max(0, item.quantity + delta) } : item + )); + } + } else { + mutateDetail(async (current) => { + if (!current) return current; + return { + ...current, + data: current.data.map(item => + item._id === itemId ? { ...item, quantity: Math.max(0, item.quantity + delta) } : item + ) + }; + }, { revalidate: false }); + } + try { await adjustCabinetItemQuantity(householdId, itemId, { delta }); - fetchData(); + mutateSummary(); + mutateDetail(); + if (expandedMedicine) refreshExpanded(expandedMedicine); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to adjust quantity'); + mutateSummary(); + mutateDetail(); } } async function handleDelete(itemId: string) { - if (!householdId || !confirm('Delete this item permanently?')) return; + if (!householdId || !window.confirm('Delete this item permanently?')) return; try { await deleteCabinetItem(householdId, itemId); - fetchData(); + mutateSummary(); + mutateDetail(); + if (expandedMedicine) refreshExpanded(expandedMedicine); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to delete'); } @@ -394,7 +428,8 @@ export function CabinetTab({ householdId }: { householdId: string }) { householdId={householdId} onCreated={() => { setShowForm(false); - fetchData(); + mutateSummary(); + mutateDetail(); }} onCancel={() => setShowForm(false)} /> @@ -447,7 +482,7 @@ function SummaryView({ expandedItems: CabinetItem[]; expandLoading: boolean; onExpand: (id: string) => void; - onAdjust: (id: string, delta: number) => void; + onAdjust: (id: string, delta: number) => Promise; onDelete: (id: string) => void; }) { if (items.length === 0) { @@ -597,7 +632,7 @@ function DetailView({ onDelete, }: { items: CabinetItem[]; - onAdjust: (id: string, delta: number) => void; + onAdjust: (id: string, delta: number) => Promise; onDelete: (id: string) => void; }) { if (items.length === 0) { @@ -639,9 +674,11 @@ function CabinetItemCard({ }: { item: CabinetItem; showMedicineName?: boolean; - onAdjust: (id: string, delta: number) => void; + onAdjust: (id: string, delta: number) => Promise; onDelete: (id: string) => void; }) { + const [isPending, setIsPending] = useState(false); + const statusStyle = item.status === 'active' ? { background: 'var(--ok-soft)', color: 'var(--ok)' } @@ -649,6 +686,16 @@ function CabinetItemCard({ ? { background: 'var(--danger-soft)', color: 'var(--danger)' } : { background: 'var(--bg-inset)', color: 'var(--ink-muted)' }; + async function handleAdjust(delta: number) { + if (isPending) return; + setIsPending(true); + try { + await onAdjust(item._id, delta); + } finally { + setIsPending(false); + } + } + return (
{/* Left: info */} @@ -732,7 +782,7 @@ function CabinetItemCard({ {item.status === 'active' && (
} + /> + ); + expect(screen.getByText('My Card')).toBeInTheDocument(); + expect(screen.getByText('My Subtitle')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Click Me' })).toBeInTheDocument(); + }); + + it('renders body with children', () => { + render( + +
Body Content
+
+ ); + expect(screen.getByText('Body Content')).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/components/layout/__tests__/PageHeaderContext.test.tsx b/packages/web/src/components/layout/__tests__/PageHeaderContext.test.tsx new file mode 100644 index 0000000..89183ab --- /dev/null +++ b/packages/web/src/components/layout/__tests__/PageHeaderContext.test.tsx @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { PageHeaderProvider, usePageHeader } from '../PageHeaderContext'; + +function TestComponent() { + const { header, setHeader } = usePageHeader(); + return ( +
+ {header.title} + +
+ ); +} + +describe('PageHeaderContext', () => { + it('provides and updates header state', () => { + render( + + + + ); + + expect(screen.getByTestId('title').textContent).toBe('MeshiTrack'); + + fireEvent.click(screen.getByText('Change')); + expect(screen.getByTestId('title').textContent).toBe('New Title'); + }); + + it('returns fallback when used outside provider', () => { + render(); + // Outside provider title returns '' fallback + expect(screen.getByTestId('title').textContent).toBe(''); + }); +}); diff --git a/packages/web/src/components/ui/__tests__/index.test.ts b/packages/web/src/components/ui/__tests__/index.test.ts new file mode 100644 index 0000000..ef2d5ea --- /dev/null +++ b/packages/web/src/components/ui/__tests__/index.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest'; +import * as UI from '../index'; + +describe('UI Index Exports', () => { + it('should export all defined UI components', () => { + expect(UI.Icon).toBeDefined(); + expect(UI.Card).toBeDefined(); + expect(UI.Button).toBeDefined(); + expect(UI.Pill).toBeDefined(); + expect(UI.SupplyBar).toBeDefined(); + expect(UI.Ring).toBeDefined(); + expect(UI.SparkBars).toBeDefined(); + expect(UI.IconButton).toBeDefined(); + expect(UI.Avatar).toBeDefined(); + }); +}); diff --git a/packages/web/src/lib/useShoppingListSync.test.ts b/packages/web/src/lib/useShoppingListSync.test.ts new file mode 100644 index 0000000..02ea188 --- /dev/null +++ b/packages/web/src/lib/useShoppingListSync.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useShoppingListSync } from './useShoppingListSync'; + +vi.mock('@/services/shopping-lists', () => ({ + getShoppingListSyncSocketUrl: vi.fn(() => 'ws://localhost/sync'), +})); + +class MockWebSocket { + static OPEN = 1; + static CONNECTING = 0; + url: string; + readyState = 1; // OPEN + onopen: any = null; + onmessage: any = null; + onerror: any = null; + onclose: any = null; + send = vi.fn(); + close = vi.fn(); + + constructor(url: string) { + this.url = url; + } +} + +describe('useShoppingListSync', () => { + let originalWebSocket: any; + let createdSockets: MockWebSocket[] = []; + + beforeEach(() => { + vi.clearAllMocks(); + createdSockets = []; + originalWebSocket = global.WebSocket; + const MockClass = class extends MockWebSocket { + constructor(url: string) { + super(url); + createdSockets.push(this); + } + }; + (MockClass as any).OPEN = 1; + (MockClass as any).CONNECTING = 0; + global.WebSocket = MockClass as any; + }); + + afterEach(() => { + global.WebSocket = originalWebSocket; + }); + + it('initializes and connects to the correct socket URL', () => { + const onSync = vi.fn(); + renderHook(() => useShoppingListSync('hh1', 'list1', onSync)); + + expect(createdSockets).toHaveLength(1); + expect(createdSockets[0].url).toBe('ws://localhost/sync'); + }); + + it('handles incoming item_updated messages correctly', () => { + const onSync = vi.fn(); + renderHook(() => useShoppingListSync('hh1', 'list1', onSync)); + + const ws = createdSockets[0]; + act(() => { if (ws.onopen) ws.onopen(); }); + + act(() => { + if (ws.onmessage) ws.onmessage({ data: JSON.stringify({ type: 'ITEM_UPDATED', itemId: 'item1', updates: { checked: true } }) }); + }); + + expect(onSync).toHaveBeenCalledWith({ type: 'ITEM_UPDATED', itemId: 'item1', updates: { checked: true } }); + }); + + it('broadcasts toggle item messages when connected', () => { + const onSync = vi.fn(); + const { result } = renderHook(() => useShoppingListSync('hh1', 'list1', onSync)); + + const ws = createdSockets[0]; + act(() => { if (ws.onopen) ws.onopen(); }); + + act(() => { + result.current.toggleItemCheck('item1', true); + }); + + expect(ws.send).toHaveBeenCalledWith(JSON.stringify({ + type: 'TOGGLE_ITEM', + itemId: 'item1', + checked: true, + })); + }); + + it('handles disconnect and reconnect backoff', () => { + vi.useFakeTimers(); + const onSync = vi.fn(); + const { result } = renderHook(() => useShoppingListSync('hh1', 'list1', onSync)); + + let ws = createdSockets[0]; + act(() => { if (ws.onopen) ws.onopen(); }); + expect(result.current.isConnected).toBe(true); + + act(() => { if (ws.onclose) ws.onclose({ reason: 'test' }); }); + expect(result.current.isConnected).toBe(false); + + // After 1000ms it should attempt reconnect + act(() => { vi.advanceTimersByTime(1000); }); + expect(createdSockets).toHaveLength(2); + + vi.useRealTimers(); + }); +}); diff --git a/packages/web/src/services/meal-plans.test.ts b/packages/web/src/services/meal-plans.test.ts new file mode 100644 index 0000000..b719f94 --- /dev/null +++ b/packages/web/src/services/meal-plans.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { apiClient } from './api-client'; +import * as MealPlansService from './meal-plans'; + +vi.mock('./api-client', () => ({ + apiClient: { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }, +})); + +describe('meal-plans service', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('listMealPlans with and without query', async () => { + await MealPlansService.listMealPlans('hh1'); + expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans'); + + await MealPlansService.listMealPlans('hh1', { cursor: 'cur', limit: 10 }); + expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans?cursor=cur&limit=10'); + }); + + it('getMealPlanByWeek', async () => { + await MealPlansService.getMealPlanByWeek('hh1', '2026-05-10'); + expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans/week/2026-05-10'); + }); + + it('getMealPlan', async () => { + await MealPlansService.getMealPlan('hh1', 'mp1'); + expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans/mp1'); + }); + + it('createMealPlan', async () => { + const data = { weekStartDate: '2026-05-10' } as any; + await MealPlansService.createMealPlan('hh1', data); + expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/meal-plans', data); + }); + + it('updateMealPlan', async () => { + const data = { days: [] } as any; + await MealPlansService.updateMealPlan('hh1', 'mp1', data); + expect(apiClient.patch).toHaveBeenCalledWith('/households/hh1/meal-plans/mp1', data); + }); + + it('updateMealPlanStatus', async () => { + await MealPlansService.updateMealPlanStatus('hh1', 'mp1', 'active' as any); + expect(apiClient.patch).toHaveBeenCalledWith('/households/hh1/meal-plans/mp1/status', { status: 'active' }); + }); + + it('deleteMealPlan', async () => { + await MealPlansService.deleteMealPlan('hh1', 'mp1'); + expect(apiClient.delete).toHaveBeenCalledWith('/households/hh1/meal-plans/mp1'); + }); + + it('getSuggestions', async () => { + await MealPlansService.getSuggestions('hh1', 3); + expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans/suggestions?limit=3'); + }); + + it('getShoppingGap', async () => { + await MealPlansService.getShoppingGap('hh1', 'mp1'); + expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/meal-plans/mp1/gap'); + }); +}); diff --git a/packages/web/src/services/nutrition-targets.test.ts b/packages/web/src/services/nutrition-targets.test.ts new file mode 100644 index 0000000..8f4118c --- /dev/null +++ b/packages/web/src/services/nutrition-targets.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { apiClient } from './api-client'; +import * as NutritionTargetsService from './nutrition-targets'; + +vi.mock('./api-client', () => ({ + apiClient: { + get: vi.fn(), + post: vi.fn(), + }, +})); + +describe('nutrition-targets service', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('getActiveNutritionTarget', async () => { + await NutritionTargetsService.getActiveNutritionTarget('hh1'); + expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/nutrition-targets'); + }); + + it('getNutritionTargetHistory', async () => { + await NutritionTargetsService.getNutritionTargetHistory('hh1'); + expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/nutrition-targets/history'); + }); + + it('setNutritionTarget', async () => { + const data = { calories: 2000 } as any; + await NutritionTargetsService.setNutritionTarget('hh1', data); + expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/nutrition-targets', data); + }); + + it('calculateTargetPreset', async () => { + await NutritionTargetsService.calculateTargetPreset('hh1', 2500, 'gain'); + expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/nutrition-targets/presets', { calories: 2500, strategy: 'gain' }); + }); +}); diff --git a/packages/web/src/services/prices.test.ts b/packages/web/src/services/prices.test.ts new file mode 100644 index 0000000..e8fc70c --- /dev/null +++ b/packages/web/src/services/prices.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { apiClient } from './api-client'; +import * as PricesService from './prices'; + +vi.mock('./api-client', () => ({ + apiClient: { + get: vi.fn(), + post: vi.fn(), + }, +})); + +describe('prices service', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('recordPrice', async () => { + const data = { productId: 'p1', price: 10 } as any; + await PricesService.recordPrice('hh1', data); + expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/prices', data); + }); + + it('recordBulkPrices', async () => { + const data = { storeId: 's1', items: [] } as any; + await PricesService.recordBulkPrices('hh1', data); + expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/prices/bulk', data); + }); + + it('getPriceHistory with and without query', async () => { + await PricesService.getPriceHistory('hh1', 'p1'); + expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/prices/history/p1'); + + await PricesService.getPriceHistory('hh1', 'p1', { + storeId: 's1', + startDate: '2026-05-01', + endDate: '2026-05-10', + cursor: 'cur', + limit: 10, + }); + expect(apiClient.get).toHaveBeenCalledWith( + '/households/hh1/prices/history/p1?storeId=s1&startDate=2026-05-01&endDate=2026-05-10&cursor=cur&limit=10' + ); + }); + + it('compareStores', async () => { + await PricesService.compareStores('hh1', 'p1'); + expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/prices/compare/p1'); + }); + + it('getPriceAnalytics', async () => { + await PricesService.getPriceAnalytics('hh1'); + expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/prices/analytics'); + }); +}); diff --git a/packages/web/src/services/shopping-lists.test.ts b/packages/web/src/services/shopping-lists.test.ts new file mode 100644 index 0000000..7a5dc19 --- /dev/null +++ b/packages/web/src/services/shopping-lists.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { apiClient } from './api-client'; +import * as ShoppingListsService from './shopping-lists'; + +vi.mock('./api-client', () => ({ + apiClient: { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + baseUrl: 'http://localhost:3001', + }, +})); + +describe('shopping-lists service', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('getShoppingLists', async () => { + await ShoppingListsService.getShoppingLists('hh1'); + expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/shopping-lists'); + }); + + it('getShoppingList', async () => { + await ShoppingListsService.getShoppingList('hh1', 'list1'); + expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1'); + }); + + it('createShoppingList', async () => { + const data = { name: 'Test' } as any; + await ShoppingListsService.createShoppingList('hh1', data); + expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/shopping-lists', data); + }); + + it('updateShoppingList', async () => { + const data = { name: 'Updated' } as any; + await ShoppingListsService.updateShoppingList('hh1', 'list1', data); + expect(apiClient.patch).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1', data); + }); + + it('deleteShoppingList', async () => { + await ShoppingListsService.deleteShoppingList('hh1', 'list1'); + expect(apiClient.delete).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1'); + }); + + it('addShoppingItem', async () => { + const data = { productId: 'p1' } as any; + await ShoppingListsService.addShoppingItem('hh1', 'list1', data); + expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1/items', data); + }); + + it('updateShoppingItem', async () => { + const data = { checked: true } as any; + await ShoppingListsService.updateShoppingItem('hh1', 'list1', 'item1', data); + expect(apiClient.patch).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1/items/item1', data); + }); + + it('removeShoppingItem', async () => { + await ShoppingListsService.removeShoppingItem('hh1', 'list1', 'item1'); + expect(apiClient.delete).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1/items/item1'); + }); + + it('generateFromMealPlan', async () => { + await ShoppingListsService.generateFromMealPlan('hh1', 'mp1'); + expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/shopping-lists/from-meal-plan/mp1'); + }); + + it('syncToPantry', async () => { + await ShoppingListsService.syncToPantry('hh1', 'list1'); + expect(apiClient.post).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1/sync-to-pantry'); + }); + + it('getBasketStoreComparison', async () => { + await ShoppingListsService.getBasketStoreComparison('hh1', 'list1'); + expect(apiClient.get).toHaveBeenCalledWith('/households/hh1/shopping-lists/list1/stores'); + }); + + it('getShoppingListSyncSocketUrl handles insecure and secure contexts', () => { + const urlInsecure = ShoppingListsService.getShoppingListSyncSocketUrl('hh1', 'list1'); + expect(urlInsecure).toBe('ws://localhost:3001/households/hh1/shopping-lists/list1/sync'); + + apiClient.baseUrl = 'https://api.meshitrack.com'; + const urlSecure = ShoppingListsService.getShoppingListSyncSocketUrl('hh1', 'list1'); + expect(urlSecure).toBe('wss://api.meshitrack.com/households/hh1/shopping-lists/list1/sync'); + }); +}); diff --git a/packages/web/src/styles/globals.css b/packages/web/src/styles/globals.css index 79137f3..ca4b080 100644 --- a/packages/web/src/styles/globals.css +++ b/packages/web/src/styles/globals.css @@ -1,100 +1,95 @@ @import 'tailwindcss'; -/* ─── Design tokens ────────────────────────────────────────────────────────── - Declared as CSS custom properties so they work in inline styles and SVGs. - Tailwind v4 @theme maps them to utility classes (bg-bg, text-ink, etc.) - ─────────────────────────────────────────────────────────────────────────── */ +/* ─── Premium Design Tokens ─────────────────────────────────────────────── */ :root { /* Type */ - --font-sans: 'Inter Tight', -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif; - --font-display: 'Fraunces', 'Inter Tight', serif; + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif; + --font-display: 'Outfit', 'Inter', sans-serif; --font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace; - /* Neutrals (warm off-white → near-black) */ - --bg: #f6f4ef; + /* Neutrals - Light Theme (Ultra clean slate/zinc) */ + --bg: #fafafa; --bg-elev: #ffffff; - --bg-inset: #efebe3; - --border: #e4dfd4; - --border-strong: #cfc8b8; - --ink-faint: #a39b89; - --ink-muted: #6e6759; - --ink: #1d1b17; - --ink-strong: #0b0a08; + --bg-inset: #f4f4f5; + --border: #e4e4e7; + --border-strong: #d4d4d8; + --ink-faint: #a1a1aa; + --ink-muted: #71717a; + --ink: #3f3f46; + --ink-strong: #09090b; - /* Brand — sage-leaning green */ - --brand: #2f6b4a; - --brand-deep: #1e4a32; + /* Brand - (Overwritten dynamically by ThemeProvider) */ + --brand: #10b981; + --brand-deep: #059669; --brand-ink: #ffffff; - --brand-soft: #e6efe8; - --brand-soft-ink: #1e4a32; + --brand-soft: rgba(16, 185, 129, 0.1); + --brand-soft-ink: #047857; /* Status */ - --danger: #b8361a; - --danger-soft: #fbe8e0; - --warn: #a66a0a; - --warn-soft: #f9ecd2; - --ok: #3e7a4c; - --ok-soft: #e3ede0; - --info: #3a5a85; - --info-soft: #e1e8f1; + --danger: #ef4444; + --danger-soft: #fef2f2; + --warn: #f59e0b; + --warn-soft: #fffbeb; + --ok: #10b981; + --ok-soft: #ecfdf5; + --info: #3b82f6; + --info-soft: #eff6ff; /* Data viz */ - --viz-1: #2f6b4a; - --viz-2: #a66a0a; - --viz-3: #3a5a85; - --viz-4: #8a4c6e; - --viz-5: #6b6237; - --viz-6: #7a3a28; + --viz-1: var(--brand); + --viz-2: #f59e0b; + --viz-3: #3b82f6; + --viz-4: #ec4899; + --viz-5: #8b5cf6; + --viz-6: #14b8a6; /* Shape */ - --r-xs: 6px; - --r-sm: 10px; - --r-md: 14px; - --r-lg: 20px; - --r-xl: 28px; + --r-xs: 8px; + --r-sm: 12px; + --r-md: 16px; + --r-lg: 24px; + --r-xl: 32px; - /* Shadows */ - --shadow-sm: 0 1px 2px rgba(20, 18, 10, 0.04), 0 0 0 1px rgba(20, 18, 10, 0.04); - --shadow-md: 0 4px 16px -6px rgba(20, 18, 10, 0.08), 0 0 0 1px rgba(20, 18, 10, 0.05); - --shadow-lg: 0 20px 40px -20px rgba(20, 18, 10, 0.22), 0 0 0 1px rgba(20, 18, 10, 0.06); + /* Premium Shadows */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04), 0 0 0 1px rgba(0, 0, 0, 0.02); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.04), 0 2px 4px rgba(0, 0, 0, 0.02), 0 0 0 1px rgba(0, 0, 0, 0.02); + --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.06), 0 4px 12px rgba(0, 0, 0, 0.03), 0 0 0 1px rgba(0, 0, 0, 0.02); + --shadow-glow: 0 0 20px var(--brand-soft); + --ease-spring: cubic-bezier(0.175, 0.885, 0.32, 1.1); + --ease-smooth: cubic-bezier(0.2, 0.8, 0.2, 1); } [data-theme='dark'] { - --bg: #141310; - --bg-elev: #1c1b17; - --bg-inset: #100f0c; - --border: #2a2823; - --border-strong: #3a372f; - --ink-faint: #6a6559; - --ink-muted: #9a9386; - --ink: #ece8de; - --ink-strong: #f8f5ec; + --bg: #09090b; + --bg-elev: #121214; + --bg-inset: #18181b; + --border: #27272a; + --border-strong: #3f3f46; + --ink-faint: #52525b; + --ink-muted: #a1a1aa; + --ink: #e4e4e7; + --ink-strong: #ffffff; - --brand: #5fa87b; - --brand-deep: #8fc9a4; - --brand-ink: #0b0a08; - --brand-soft: #1e2c23; - --brand-soft-ink: #8fc9a4; + --danger: #f87171; + --danger-soft: rgba(248, 113, 113, 0.1); + --warn: #fbbf24; + --warn-soft: rgba(251, 191, 36, 0.1); + --ok: #34d399; + --ok-soft: rgba(52, 211, 153, 0.1); + --info: #60a5fa; + --info-soft: rgba(96, 165, 250, 0.1); - --danger: #e37358; - --danger-soft: #2c1a15; - --warn: #d6a152; - --warn-soft: #2c2217; - --ok: #7aba89; - --ok-soft: #1a2820; - --info: #7ea3cf; - --info-soft: #1a2028; + --viz-1: var(--brand); + --viz-2: #fbbf24; + --viz-3: #60a5fa; + --viz-4: #f472b6; + --viz-5: #a78bfa; + --viz-6: #2dd4bf; - --viz-1: #5fa87b; - --viz-2: #d6a152; - --viz-3: #7ea3cf; - --viz-4: #c088a5; - --viz-5: #b8ac76; - --viz-6: #d88a74; - - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.04); - --shadow-md: 0 4px 16px -6px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05); - --shadow-lg: 0 20px 40px -20px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.06); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.05); + --shadow-md: 0 8px 16px rgba(0, 0, 0, 0.6), 0 2px 4px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05); + --shadow-lg: 0 20px 40px rgba(0, 0, 0, 0.8), 0 0 0 1px rgba(255, 255, 255, 0.05); + --shadow-glow: 0 0 30px var(--brand-soft); } /* ─── Tailwind v4 theme mappings ─────────────────────────────────────────── */ @@ -144,17 +139,20 @@ body { body { font-family: var(--font-sans); - font-feature-settings: 'ss01', 'cv11'; + font-feature-settings: 'ss01', 'cv11', 'salt'; color: var(--ink); background: var(--bg); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; font-size: 14px; - line-height: 1.45; - letter-spacing: -0.005em; - transition: - background-color 0.2s, - color 0.2s; + line-height: 1.5; + letter-spacing: -0.01em; + transition: background-color 0.4s var(--ease-smooth), color 0.4s var(--ease-smooth); +} + +h1, h2, h3, h4, h5, h6 { + font-family: var(--font-display); + letter-spacing: -0.02em; } button { @@ -164,6 +162,7 @@ button { border: 0; background: none; padding: 0; + outline: none; } input, select, @@ -174,6 +173,7 @@ textarea { a { color: inherit; text-decoration: none; + transition: color 0.2s var(--ease-smooth); } ::selection { @@ -182,15 +182,15 @@ a { } ::-webkit-scrollbar { - width: 10px; - height: 10px; + width: 8px; + height: 8px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: var(--border-strong); - border-radius: 10px; + border-radius: 8px; border: 2px solid var(--bg); } @@ -207,7 +207,7 @@ a { /* ─── Page content wrapper ───────────────────────────────────────────────── */ .mt-page { - padding: 28px 32px 56px; + padding: 32px 40px 64px; max-width: 1400px; width: 100%; } @@ -216,18 +216,24 @@ a { .mt-pill { display: inline-flex; align-items: center; - gap: 4px; - padding: 2px 8px; + gap: 6px; + padding: 4px 10px; border-radius: 999px; font-size: 11px; - font-weight: 500; - line-height: 1.6; - letter-spacing: 0.01em; + font-weight: 600; + line-height: 1.4; + letter-spacing: 0.02em; + text-transform: uppercase; border: 1px solid transparent; + transition: all 0.2s var(--ease-smooth); } .mt-pill--brand { background: var(--brand-soft); color: var(--brand-soft-ink); + border-color: rgba(0, 0, 0, 0.05); +} +[data-theme='dark'] .mt-pill--brand { + border-color: rgba(255, 255, 255, 0.05); } .mt-pill--ok { background: var(--ok-soft); @@ -253,21 +259,32 @@ a { .mt-seg { display: inline-flex; background: var(--bg-inset); - border-radius: 8px; - padding: 3px; - gap: 0; + border-radius: var(--r-sm); + padding: 4px; + gap: 2px; + box-shadow: inset 0 1px 2px rgba(0,0,0,0.05); +} +[data-theme='dark'] .mt-seg { + box-shadow: inset 0 1px 2px rgba(0,0,0,0.5); } .mt-seg button { - padding: 5px 12px; - border-radius: 6px; - font-size: 12px; + padding: 6px 16px; + border-radius: 8px; + font-size: 13px; font-weight: 500; color: var(--ink-muted); - transition: all 0.12s; + transition: all 0.25s var(--ease-spring); +} +.mt-seg button:hover:not(.is-active) { + color: var(--ink); + background: rgba(0,0,0,0.02); +} +[data-theme='dark'] .mt-seg button:hover:not(.is-active) { + background: rgba(255,255,255,0.02); } .mt-seg button.is-active { background: var(--bg-elev); - color: var(--ink); + color: var(--ink-strong); box-shadow: var(--shadow-sm); } @@ -278,43 +295,53 @@ a { gap: 1px; background: var(--border); border: 1px solid var(--border); - border-radius: var(--r-md); + border-radius: var(--r-lg); overflow: hidden; + box-shadow: var(--shadow-sm); } .cab-summary__item { background: var(--bg-elev); - padding: 16px 20px; + padding: 20px 24px; display: flex; flex-direction: column; - gap: 3px; + gap: 4px; + transition: background 0.2s var(--ease-smooth); +} +.cab-summary__item:hover { + background: var(--bg-inset); } .cab-summary__label { - font-size: 11px; + font-size: 12px; color: var(--ink-muted); text-transform: uppercase; - letter-spacing: 0.06em; - font-weight: 500; + letter-spacing: 0.08em; + font-weight: 600; } .cab-summary__value { - font-size: 24px; - font-weight: 600; - letter-spacing: -0.02em; + font-size: 32px; + font-weight: 700; + font-family: var(--font-display); + letter-spacing: -0.03em; color: var(--ink-strong); - line-height: 1.15; + line-height: 1.1; } .cab-summary__item--danger .cab-summary__value { color: var(--danger); + text-shadow: 0 0 16px var(--danger-soft); } .cab-summary__item--warn .cab-summary__value { color: var(--warn); + text-shadow: 0 0 16px var(--warn-soft); } .cab-summary__item--brand .cab-summary__value { color: var(--brand); + text-shadow: 0 0 16px var(--brand-soft); } .cab-summary__hint { - font-size: 11px; + font-size: 12px; color: var(--ink-faint); - margin-top: 2px; + margin-top: 4px; + font-weight: 500; } /* ─── Cabinet — filter rail ─────────────────────────────────────────────── */ @@ -322,116 +349,150 @@ a { display: flex; justify-content: space-between; align-items: center; - gap: 12px; + gap: 16px; flex-wrap: wrap; + margin: 24px 0; } .cab-filters__tabs { display: flex; - gap: 3px; + gap: 4px; background: var(--bg-elev); - padding: 3px; + padding: 4px; border-radius: var(--r-sm); border: 1px solid var(--border); + box-shadow: var(--shadow-sm); } .cab-filters__tab { - padding: 6px 12px; - font-size: 12px; - font-weight: 500; + padding: 8px 16px; + font-size: 13px; + font-weight: 600; color: var(--ink-muted); - border-radius: 7px; + border-radius: 8px; display: inline-flex; align-items: center; - gap: 6px; - transition: all 0.12s; + gap: 8px; + transition: all 0.25s var(--ease-spring); } .cab-filters__tab:hover { - color: var(--ink); + color: var(--ink-strong); + background: var(--bg-inset); } .cab-filters__tab.is-active { - background: var(--ink); - color: var(--bg-elev); + background: var(--ink-strong); + color: var(--bg); + box-shadow: 0 2px 8px rgba(0,0,0,0.1); +} +[data-theme='dark'] .cab-filters__tab.is-active { + box-shadow: 0 2px 8px rgba(0,0,0,0.4); } .cab-filters__count { - font-size: 10px; - padding: 1px 5px; - border-radius: 6px; + font-size: 11px; + padding: 2px 6px; + border-radius: 999px; background: var(--bg-inset); color: var(--ink-muted); + font-weight: 700; } .cab-filters__tab.is-active .cab-filters__count { - background: rgba(255, 255, 255, 0.15); - color: var(--bg-elev); + background: rgba(255, 255, 255, 0.2); + color: var(--bg); +} +[data-theme='dark'] .cab-filters__tab.is-active .cab-filters__count { + background: rgba(0, 0, 0, 0.3); } .cab-filters__tools { display: flex; - gap: 8px; + gap: 12px; align-items: center; } .cab-filters__search { display: flex; align-items: center; - gap: 6px; + gap: 8px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--r-sm); - padding: 6px 10px; + padding: 8px 12px; color: var(--ink-muted); - transition: border-color 0.15s; + transition: all 0.2s var(--ease-smooth); + box-shadow: var(--shadow-sm); } .cab-filters__search:focus-within { border-color: var(--brand); + box-shadow: 0 0 0 3px var(--brand-soft); + color: var(--brand); } .cab-filters__search input { border: 0; outline: 0; background: transparent; - width: 130px; - font-size: 13px; - color: var(--ink); + width: 160px; + font-size: 14px; + color: var(--ink-strong); + font-weight: 500; } .cab-filters__search input::placeholder { color: var(--ink-faint); + font-weight: 400; } /* ─── Cabinet — card grid ───────────────────────────────────────────────── */ .cab-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); - gap: 14px; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 20px; } .cab-card { text-align: left; display: flex; flex-direction: column; - gap: 14px; - padding: 18px; + gap: 16px; + padding: 20px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--r-md); - transition: all 0.15s; + transition: all 0.3s var(--ease-spring); position: relative; overflow: hidden; width: 100%; cursor: pointer; + box-shadow: var(--shadow-sm); + z-index: 1; +} +.cab-card::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0) 100%); + opacity: 0; + transition: opacity 0.3s var(--ease-smooth); + z-index: -1; + pointer-events: none; +} +[data-theme='dark'] .cab-card::after { + background: linear-gradient(180deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0) 100%); } .cab-card:hover { border-color: var(--border-strong); - transform: translateY(-1px); + transform: translateY(-4px) scale(1.01); box-shadow: var(--shadow-md); } +.cab-card:hover::after { + opacity: 1; +} .cab-card::before { content: ''; position: absolute; top: 0; left: 0; - right: 0; - height: 3px; + bottom: 0; + width: 4px; background: var(--viz-1); opacity: 0; - transition: opacity 0.15s; + transition: opacity 0.3s var(--ease-smooth); } .cab-card:hover::before { - opacity: 0.5; + opacity: 1; } .cab-card--lvl-expiring::before { background: var(--warn); @@ -443,34 +504,42 @@ a { } .cab-card--lvl-expiring { - border-color: color-mix(in oklab, var(--warn) 30%, var(--border)); + border-color: color-mix(in oklab, var(--warn) 40%, var(--border)); + background: linear-gradient(to bottom right, var(--bg-elev), var(--warn-soft)); } .cab-card--lvl-critical { - border-color: color-mix(in oklab, var(--danger) 30%, var(--border)); + border-color: color-mix(in oklab, var(--danger) 40%, var(--border)); + background: linear-gradient(to bottom right, var(--bg-elev), var(--danger-soft)); } .cab-card__top { display: flex; align-items: center; - gap: 10px; + gap: 12px; } .cab-card__swatch { - width: 34px; - height: 34px; + width: 42px; + height: 42px; border-radius: var(--r-sm); display: grid; place-items: center; - background: color-mix(in oklab, var(--c, var(--viz-1)) 14%, var(--bg-inset)); + background: color-mix(in oklab, var(--c, var(--viz-1)) 15%, var(--bg-inset)); color: var(--c, var(--viz-1)); flex-shrink: 0; + box-shadow: inset 0 2px 4px rgba(255,255,255,0.5); + font-size: 18px; +} +[data-theme='dark'] .cab-card__swatch { + box-shadow: inset 0 2px 4px rgba(255,255,255,0.05); } .cab-card__meta { flex: 1; min-width: 0; } .cab-card__name { - font-size: 14px; - font-weight: 500; + font-size: 16px; + font-weight: 600; + font-family: var(--font-display); color: var(--ink-strong); letter-spacing: -0.01em; white-space: nowrap; @@ -478,9 +547,10 @@ a { text-overflow: ellipsis; } .cab-card__strength { - font-size: 11px; + font-size: 12px; color: var(--ink-muted); margin-top: 2px; + font-weight: 500; } .cab-card__flag { flex-shrink: 0; @@ -490,38 +560,41 @@ a { display: flex; align-items: baseline; gap: 6px; + margin-top: 4px; } .cab-card__qty-num { - font-size: 26px; - font-weight: 600; - letter-spacing: -0.02em; + font-size: 32px; + font-weight: 700; + font-family: var(--font-display); + letter-spacing: -0.03em; color: var(--ink-strong); line-height: 1; } .cab-card__qty-unit { - font-size: 12px; + font-size: 13px; + font-weight: 600; color: var(--ink-muted); } .cab-card__foot { display: grid; grid-template-columns: 1fr 1fr 1fr; - gap: 8px; - padding-top: 12px; - border-top: 1px dashed var(--border); + gap: 12px; + padding-top: 16px; + border-top: 1px solid var(--border); } .cab-card__foot-label { - font-size: 10px; + font-size: 11px; color: var(--ink-faint); text-transform: uppercase; - letter-spacing: 0.06em; - font-weight: 500; + letter-spacing: 0.08em; + font-weight: 600; } .cab-card__foot-value { - font-size: 12px; + font-size: 13px; color: var(--ink); - font-weight: 500; - margin-top: 2px; + font-weight: 600; + margin-top: 4px; } .cab-card__foot-value--warn { color: var(--warn); @@ -531,42 +604,50 @@ a { .daysbar { display: flex; align-items: center; - gap: 10px; + gap: 12px; } .daysbar__track { flex: 1; - height: 6px; + height: 8px; background: var(--bg-inset); - border-radius: 3px; + border-radius: 4px; position: relative; overflow: hidden; + box-shadow: inset 0 1px 2px rgba(0,0,0,0.06); +} +[data-theme='dark'] .daysbar__track { + box-shadow: inset 0 1px 2px rgba(0,0,0,0.4); } .daysbar__fill { height: 100%; - background: var(--ok); - border-radius: 3px; - transition: width 0.3s; + background: linear-gradient(90deg, var(--ok-soft) 0%, var(--ok) 100%); + border-radius: 4px; + transition: width 0.5s var(--ease-spring); + box-shadow: 0 0 10px var(--ok-soft); } .daysbar--low .daysbar__fill { - background: var(--warn); + background: linear-gradient(90deg, var(--warn-soft) 0%, var(--warn) 100%); + box-shadow: 0 0 10px var(--warn-soft); } .daysbar--critical .daysbar__fill { - background: var(--danger); + background: linear-gradient(90deg, var(--danger-soft) 0%, var(--danger) 100%); + box-shadow: 0 0 10px var(--danger-soft); } .daysbar__label { display: flex; align-items: baseline; - gap: 3px; - min-width: 46px; + gap: 4px; + min-width: 50px; justify-content: flex-end; } .daysbar__num { - font-size: 13px; - font-weight: 600; - color: var(--ink); + font-size: 14px; + font-weight: 700; + color: var(--ink-strong); } .daysbar__unit { - font-size: 10px; + font-size: 11px; + font-weight: 600; color: var(--ink-muted); } .daysbar--low .daysbar__num { @@ -582,43 +663,51 @@ a { border-radius: var(--r-md); background: var(--bg-elev); overflow: hidden; - margin-top: 4px; + margin-top: 8px; + box-shadow: var(--shadow-sm); } .cab-items__row { display: flex; align-items: center; justify-content: space-between; - gap: 12px; - padding: 12px 16px; + gap: 16px; + padding: 16px 20px; border-bottom: 1px solid var(--border); + transition: background 0.2s; } .cab-items__row:last-child { border-bottom: 0; } +.cab-items__row:hover { + background: var(--bg-inset); +} /* ─── Cabinet — detail list view ────────────────────────────────────────── */ .cab-list { display: flex; flex-direction: column; - gap: 2px; + gap: 0; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--bg-elev); overflow: hidden; + box-shadow: var(--shadow-sm); } .cab-list__row { display: flex; align-items: center; justify-content: space-between; - gap: 12px; - padding: 12px 16px; + gap: 16px; + padding: 16px 20px; border-bottom: 1px solid var(--border); + transition: all 0.2s var(--ease-smooth); } .cab-list__row:last-child { border-bottom: 0; } .cab-list__row:hover { background: var(--bg-inset); + transform: translateX(2px); } /* ─── Form field base ───────────────────────────────────────────────────── */ @@ -627,82 +716,110 @@ a { background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--r-sm); - padding: 8px 12px; - font-size: 13px; - color: var(--ink); + padding: 10px 14px; + font-size: 14px; + color: var(--ink-strong); + font-weight: 500; outline: none; - transition: border-color 0.15s; + transition: all 0.2s var(--ease-smooth); + box-shadow: var(--shadow-sm); } .mt-field:focus { border-color: var(--brand); + box-shadow: 0 0 0 3px var(--brand-soft); } .mt-field::placeholder { color: var(--ink-faint); + font-weight: 400; } .mt-field:disabled { background: var(--bg-inset); color: var(--ink-faint); cursor: not-allowed; + box-shadow: none; } .mt-field-label { display: block; - font-size: 12px; - font-weight: 500; + font-size: 13px; + font-weight: 600; color: var(--ink-muted); - margin-bottom: 5px; + margin-bottom: 6px; } /* ─── Buttons ───────────────────────────────────────────────────────────── */ .mt-btn { display: inline-flex; align-items: center; - gap: 6px; - padding: 7px 14px; + justify-content: center; + gap: 8px; + padding: 10px 18px; border-radius: var(--r-sm); - font-size: 13px; - font-weight: 500; - transition: all 0.12s; + font-size: 14px; + font-weight: 600; + transition: all 0.25s var(--ease-spring); cursor: pointer; border: 1px solid transparent; + user-select: none; +} +.mt-btn:active { + transform: scale(0.96); } .mt-btn--primary { - background: var(--brand); + background: linear-gradient(180deg, color-mix(in oklab, var(--brand) 90%, white), var(--brand)); color: var(--brand-ink); - border-color: var(--brand); + border-color: var(--brand-deep); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.2), 0 2px 4px rgba(0,0,0,0.1); + text-shadow: 0 1px 2px rgba(0,0,0,0.1); +} +[data-theme='dark'] .mt-btn--primary { + background: linear-gradient(180deg, var(--brand), color-mix(in oklab, var(--brand) 80%, black)); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.1), 0 2px 8px rgba(0,0,0,0.3); } .mt-btn--primary:hover { - background: var(--brand-deep); - border-color: var(--brand-deep); + filter: brightness(1.1); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.3), 0 4px 12px rgba(0,0,0,0.15); + transform: translateY(-1px); } .mt-btn--primary:disabled { - opacity: 0.5; + opacity: 0.6; cursor: not-allowed; + filter: grayscale(0.5); + transform: none; + box-shadow: none; } .mt-btn--ghost { background: transparent; color: var(--ink-muted); border-color: var(--border); + box-shadow: var(--shadow-sm); } .mt-btn--ghost:hover { - background: var(--bg-inset); - color: var(--ink); + background: var(--bg-elev); + color: var(--ink-strong); + border-color: var(--border-strong); + box-shadow: var(--shadow-md); + transform: translateY(-1px); } .mt-btn--icon { - padding: 6px; + padding: 8px; background: transparent; color: var(--ink-muted); border-color: var(--border); - border-radius: var(--r-xs); + border-radius: var(--r-sm); + box-shadow: var(--shadow-sm); } .mt-btn--icon:hover { - background: var(--bg-inset); - color: var(--ink); + background: var(--bg-elev); + color: var(--ink-strong); + border-color: var(--border-strong); + box-shadow: var(--shadow-md); + transform: translateY(-1px); } .mt-btn--danger-icon { - padding: 5px; + padding: 8px; background: transparent; color: var(--ink-faint); - border-radius: var(--r-xs); + border-radius: var(--r-sm); } .mt-btn--danger-icon:hover { background: var(--danger-soft); @@ -712,37 +829,68 @@ a { background: transparent; color: var(--danger); border-color: var(--danger); + box-shadow: var(--shadow-sm); } .mt-btn--danger-ghost:hover { background: var(--danger-soft); + transform: translateY(-1px); + box-shadow: var(--shadow-md); } /* ─── Cards ──────────────────────────────────────────────────────────────── */ .mt-card { background: var(--bg-elev); border: 1px solid var(--border); - border-radius: var(--r-md); - padding: 24px; + border-radius: var(--r-lg); + padding: 32px; + box-shadow: var(--shadow-sm); + transition: box-shadow 0.3s var(--ease-smooth); +} +.mt-card:hover { + box-shadow: var(--shadow-md); } /* ─── Alerts ─────────────────────────────────────────────────────────────── */ .mt-alert { - padding: 10px 14px; - border-radius: var(--r-sm); - font-size: 13px; + padding: 12px 16px; + border-radius: var(--r-md); + font-size: 14px; + font-weight: 500; border: 1px solid transparent; + display: flex; + align-items: center; + gap: 12px; } .mt-alert--danger { background: var(--danger-soft); - border-color: var(--danger); + border-color: color-mix(in oklab, var(--danger) 30%, transparent); color: var(--danger); + box-shadow: 0 2px 8px var(--danger-soft); } /* ─── Links ──────────────────────────────────────────────────────────────── */ .mt-link { color: var(--brand); - text-decoration: underline; + text-decoration: none; + font-weight: 500; + position: relative; +} +.mt-link::after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; + width: 100%; + height: 1px; + background: currentColor; + transform: scaleX(0); + transform-origin: right; + transition: transform 0.3s var(--ease-spring); } .mt-link:hover { color: var(--brand-deep); } +.mt-link:hover::after { + transform: scaleX(1); + transform-origin: left; +} diff --git a/packages/web/test_results.txt b/packages/web/test_results.txt new file mode 100644 index 0000000..da23135 Binary files /dev/null and b/packages/web/test_results.txt differ diff --git a/packages/web/test_results_2.txt b/packages/web/test_results_2.txt new file mode 100644 index 0000000..e3eb81b Binary files /dev/null and b/packages/web/test_results_2.txt differ diff --git a/packages/web/test_results_2_utf8.txt b/packages/web/test_results_2_utf8.txt new file mode 100644 index 0000000..65a62c4 --- /dev/null +++ b/packages/web/test_results_2_utf8.txt @@ -0,0 +1,1891 @@ + + RUN  v4.1.2 F:/Coding/MeshiTrack/packages/web + +node.exe : stderr | src/app/(dashboard)/shopping-lists/__tests__/page.test.tsx > +ShoppingListsPage > loads and displays shopping lists +At line:1 char:1 ++ & "C:\nodejs/node.exe" "C:\nodejs/node_modules/npm/bin/npx-cli.js" vi ... ++ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + CategoryInfo : NotSpecified: (stderr... shopping lists:String) [], RemoteException + + FullyQualifiedErrorId : NativeCommandError + +Each child in a list should have a unique "key" prop. + +Check the render method of `ShoppingListsPage`. See https://react.dev/link/warning-keys for more information. + + Γ£ô src/components/__tests__/ThemeProvider.test.tsx (8 tests) 102ms + Γ£ô src/app/__tests__/page.test.tsx (2 tests) 198ms + Γ£ô src/app/(dashboard)/medicines/__tests__/page.test.tsx (3 tests) 136ms + Γ£ô src/app/(dashboard)/products/__tests__/ImportDialog.test.tsx (11 tests) 353ms + Γ£ô src/components/__tests__/ui.test.tsx (11 tests) 284ms +stderr | src/app/(dashboard)/dashboard/__tests__/page.test.tsx > DashboardPage > +shows "good shape" message when no critical alerts +Each child in a list should have a unique "key" prop. + +Check the render method of `DashboardPage`. See https://react.dev/link/warning-keys for more information. + + Γ£ô src/components/__tests__/TopBar.test.tsx (4 tests) 331ms + Γ£ô src/components/__tests__/Sidebar.test.tsx (8 tests) 340ms + Γ£ô src/app/(dashboard)/dashboard/__tests__/page.test.tsx (13 tests) 401ms + Γ£ô src/app/(dashboard)/medicines/schedule/__tests__/page.test.tsx (14 tests) 388ms + Γ£ô src/app/(dashboard)/shopping-lists/__tests__/page.test.tsx (6 tests) 685ms + Γ£ô toggles create list modal and submits  332ms +stderr | src/app/(dashboard)/refills/__tests__/page.test.tsx > RefillsPage > +renders Refills heading when householdId exists +An update to AlertsPanel inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at +https://react.dev/link/wrap-tests-with-act +An update to AlertsPanel inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at +https://react.dev/link/wrap-tests-with-act +An update to RefillListsPanel inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at +https://react.dev/link/wrap-tests-with-act +An update to RefillListsPanel inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at +https://react.dev/link/wrap-tests-with-act + +stderr | src/app/(dashboard)/medicine-prices/__tests__/page.test.tsx > MedicinePricesPage > +renders Medicine Prices heading when householdId exists +An update to MedicinePricesContent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at +https://react.dev/link/wrap-tests-with-act +An update to MedicinePricesContent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at +https://react.dev/link/wrap-tests-with-act + + Γ£ô src/app/(dashboard)/recipes/new/__tests__/page.test.tsx (3 tests) 216ms + Γ£ô src/app/(dashboard)/recipes/[id]/edit/__tests__/page.test.tsx (4 tests) 285ms + Γ£ô src/app/(dashboard)/recipes/[id]/__tests__/page.test.tsx (11 tests) 556ms + Γ£ô src/app/(dashboard)/recipes/__tests__/page.test.tsx (22 tests) 801ms + Γ£ô src/app/(dashboard)/pantry/__tests__/page.test.tsx (15 tests) 809ms +stderr | src/app/(dashboard)/medicines/__tests__/ActivityTab.test.tsx > ActivityTab > +loads more events when Load more is clicked +Encountered two children with the same key, `ev-1`. Keys should be unique so that components maintain their +identity across updates. Non-unique keys may cause children to be duplicated and/or omitted ΓÇö the behavior is +unsupported and could change in a future version. + + Γ£ô src/app/(dashboard)/recipes/__tests__/RecipeEditor.test.tsx (9 tests) 891ms +stderr | src/app/(dashboard)/medicine-prices/__tests__/page.test.tsx > MedicinePricesPage > +shows Load more button in price history +Encountered two children with the same key, `pr-1`. Keys should be unique so that components maintain their +identity across updates. Non-unique keys may cause children to be duplicated and/or omitted ΓÇö the behavior is +unsupported and could change in a future version. + + Γ¥» src/app/(dashboard)/shopping-lists/prices/__tests__/page.test.tsx (3 tests | 3 failed) 2108ms + ├ù renders loading initially 62ms + ├ù loads and displays analytics 1022ms + ├ù handles fetch errors gracefully 1020ms + Γ£ô src/app/(dashboard)/medicines/__tests__/ActivityTab.test.tsx (18 tests) 940ms + Γ£ô src/app/(dashboard)/stores/__tests__/page.test.tsx (22 tests) 2353ms + Γ£ô toggles a preset tag and adds a custom tag  491ms + Γ£ô src/app/(dashboard)/purchases/__tests__/page.test.tsx (26 tests) 2386ms + Γ£ô adds and removes purchase items  304ms + Γ£ô src/app/(dashboard)/products/__tests__/page.test.tsx (11 tests) 1243ms + Γ£ô filters by search input with debounce  442ms + Γ£ô src/app/(dashboard)/medicines/__tests__/OrganizerTab.test.tsx (20 tests) 1354ms +stderr | src/app/(dashboard)/medicines/__tests__/LibraryTab.test.tsx > LibraryTab > +filters medicines by search, category, and form +An update to LibraryTab inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at +https://react.dev/link/wrap-tests-with-act +An update to LibraryTab inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at +https://react.dev/link/wrap-tests-with-act +An update to LibraryTab inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at +https://react.dev/link/wrap-tests-with-act +An update to LibraryTab inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at +https://react.dev/link/wrap-tests-with-act +An update to LibraryTab inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at +https://react.dev/link/wrap-tests-with-act + + Γ£ô src/app/(dashboard)/medicine-prices/__tests__/page.test.tsx (15 tests) 1433ms +stderr | src/app/(dashboard)/medicines/__tests__/CabinetTab.test.tsx > CabinetTab > +changes quantity and unit in AddToCabinetForm +An update to AddToCabinetForm inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at +https://react.dev/link/wrap-tests-with-act + + Γ£ô src/app/(dashboard)/products/__tests__/ProductModal.test.tsx (18 tests) 1767ms + Γ£ô calls onSave with correct data on valid submit  342ms + Γ¥» src/app/(dashboard)/shopping-lists/[id]/__tests__/page.test.tsx (5 tests | 4 failed) 4156ms + Γ£ô renders loading initially 59ms + ├ù loads and displays the list 1012ms + ├ù toggles an item check 1047ms + ├ù adds a new item to the list 1010ms + ├ù completes the trip and syncs to pantry 1023ms + Γ£ô src/app/(dashboard)/medicines/__tests__/LibraryTab.test.tsx (14 tests) 2143ms + Γ£ô creates medicine and refreshes list  495ms + Γ£ô shows fallback error when non-Error is thrown on create medicine  448ms + Γ£ô src/app/(dashboard)/settings/__tests__/page.test.tsx (20 tests) 3791ms + Γ£ô creates a household on form submit  303ms + Γ£ô allows editing the household name  505ms + Γ£ô shows error when name update fails  460ms + Γ£ô shows fallback error when non-Error thrown on name update  324ms + Γ£ô src/app/(dashboard)/medicines/__tests__/RegimensTab.test.tsx (32 tests) 3041ms + Γ£ô src/app/(dashboard)/medicines/__tests__/CabinetTab.test.tsx (29 tests) 3127ms + Γ£ô src/app/(dashboard)/refills/__tests__/page.test.tsx (26 tests) 3150ms + Γ£ô src/app/(dashboard)/medicines/[id]/__tests__/page.test.tsx (34 tests) 3835ms + Γ£ô toggles Add Product form  367ms + Γ£ô src/app/(dashboard)/medicines/organizer/__tests__/page.test.tsx (3 tests) 103ms + Γ£ô src/app/(dashboard)/medicines/activity/__tests__/page.test.tsx (3 tests) 104ms + Γ£ô src/lib/__tests__/useApi.test.ts (7 tests) 41ms + Γ£ô src/app/(dashboard)/medicines/library/__tests__/page.test.tsx (3 tests) 97ms + Γ£ô src/app/(dashboard)/medicines/regimens/__tests__/page.test.tsx (3 tests) 92ms + Γ£ô src/app/(dashboard)/medicines/cabinet/__tests__/page.test.tsx (3 tests) 102ms +stdout | src/lib/useShoppingListSync.test.ts > useShoppingListSync > handles incoming item_updated messages correctly +≡ƒ¢Æ Connected to shopping list real-time sync: list1 + + Γ£ô src/app/(dashboard)/__tests__/loading.test.tsx (1 test) 37ms +stdout | src/lib/useShoppingListSync.test.ts > useShoppingListSync > broadcasts toggle item messages when connected +≡ƒ¢Æ Connected to shopping list real-time sync: list1 + +stdout | src/lib/useShoppingListSync.test.ts > useShoppingListSync > handles disconnect and reconnect backoff +≡ƒ¢Æ Connected to shopping list real-time sync: list1 +≡ƒöî Sync severed: test +≡ƒöä Attempting sync handshake reconnect (1/5)... + + Γ£ô src/lib/useShoppingListSync.test.ts (4 tests) 44ms + Γ£ô src/app/(dashboard)/__tests__/layout.test.tsx (1 test) 40ms + Γ£ô src/services/__tests__/api-client.test.ts (14 tests) 20ms + Γ£ô src/services/__tests__/products.test.ts (12 tests) 19ms + Γ£ô src/services/__tests__/organizer.test.ts (7 tests) 12ms + Γ£ô src/services/meal-plans.test.ts (9 tests) 12ms + Γ£ô src/services/__tests__/medicines.test.ts (12 tests) 16ms + Γ£ô src/services/__tests__/regimens.test.ts (8 tests) 12ms + Γ£ô src/services/__tests__/pantry.test.ts (13 tests) 12ms + Γ£ô src/services/__tests__/stores.test.ts (6 tests) 12ms + Γ£ô src/services/__tests__/cabinet-events.test.ts (7 tests) 14ms + Γ£ô src/services/__tests__/cabinet.test.ts (11 tests) 12ms + Γ£ô src/services/__tests__/purchases.test.ts (7 tests) 14ms + Γ£ô src/services/__tests__/refills.test.ts (9 tests) 13ms + Γ£ô src/services/shopping-lists.test.ts (12 tests) 8ms + Γ£ô src/services/nutrition-targets.test.ts (4 tests) 8ms + Γ£ô src/services/prices.test.ts (5 tests) 6ms + Γ£ô src/services/__tests__/medicine-prices.test.ts (7 tests) 12ms + Γ£ô src/services/__tests__/recipes.test.ts (11 tests) 12ms + Γ£ô src/services/__tests__/households.test.ts (5 tests) 7ms + +ΓÄ»ΓÄ»ΓÄ»ΓÄ»ΓÄ»ΓÄ»ΓÄ» Failed Tests 7 ΓÄ»ΓÄ»ΓÄ»ΓÄ»ΓÄ»ΓÄ»ΓÄ» + + FAIL  src/app/(dashboard)/shopping-lists/prices/__tests__/page.test.tsx > +ShoppingListPricesPage > renders loading initially +TestingLibraryElementError: Unable to find an element with the text: Price Intelligence. This could be +because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to +make your matcher more flexible. + +Ignored nodes: comments, script, style + + 
 +  + Synthesizing financial graphs... + 
 + 
 + + ❯ Object.getElementError ../../node_modules/@testing-library/dom/dist/config.js:37:19 + ❯ ../../node_modules/@testing-library/dom/dist/query-helpers.js:76:38 + ❯ ../../node_modules/@testing-library/dom/dist/query-helpers.js:52:17 + ❯ ../../node_modules/@testing-library/dom/dist/query-helpers.js:95:19 + ❯ src/app/(dashboard)/shopping-lists/prices/__tests__/page.test.tsx:56:19 +  54| vi.mocked(useApiModule.useApi).mockReturnValue({ householdId: null… +  55| render(<ShoppingListPricesPage />); +  56| expect(screen.getByText('Price +Intelligence')).toBeInTheDocument(); +  | ^ +  57| }); +  58| + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/7]⎯ + + FAIL  src/app/(dashboard)/shopping-lists/prices/__tests__/page.test.tsx > +ShoppingListPricesPage > loads and displays analytics +TestingLibraryElementError: Unable to find an element with the text: $150.00. This could be because +the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make +your matcher more flexible. + +Ignored nodes: comments, script, style + + 
 +  + Inflation & Spend Metrics +  +  +  +  +  +  +  +  Back to Checklists +  + 
 +  +  +  + Monthly Spending Velocities +  +  +  + No historical spend records found. + 
 +  +  +  +  + Spending Distrubution by Category +  +  +  + No categorized allocations recorded yet. +  +  +  +  +  +  + Average Complete Basket Totals per Store +  +  + Create multiple shopping trips to visualize basket trends. +  +  +  +  + + +Ignored nodes: comments, script, style + +  +  + 
 +  + Inflation & Spend Metrics +  +  +  +  +  +  +  +  Back to Checklists +  + 
 +  +  +  + Monthly Spending Velocities +  +  +  + No historical spend records found. +  +  +  +  +  + Spending Distrubution by Category +  +  +  + No categorized allocations recorded yet. +  +  +  +  +  +  + Average Complete Basket Totals per Store +  +  + Create multiple shopping trips to visualize basket trends. +  +  +  +  +  + + ❯ Proxy.waitForWrapper ../../node_modules/@testing-library/dom/dist/wait-for.js:163:27 + ❯ src/app/(dashboard)/shopping-lists/prices/__tests__/page.test.tsx:61:11 +  59| it('loads and displays analytics', async () => { +  60| render(<ShoppingListPricesPage />); +  61| await waitFor(() => { +  | ^ +  62| expect(screen.getByText('$150.00')).toB +eInTheDocument(); +  63| expect(screen.getByText('Costco')).toBe +InTheDocument(); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/7]⎯ + + FAIL  src/app/(dashboard)/shopping-lists/prices/__tests__/page.test.tsx > +ShoppingListPricesPage > handles fetch errors gracefully +TestingLibraryElementError: Unable to find an element with the text: Analytics failed. This could be +because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to +make your matcher more flexible. + +Ignored nodes: comments, script, style + + 
 +  + Error:  + Analytics failed + 
 +  + + +Ignored nodes: comments, script, style + +  +  + 
 +  + Error:  + Analytics failed + 
 +  +  + + ❯ Proxy.waitForWrapper ../../node_modules/@testing-library/dom/dist/wait-for.js:163:27 + ❯ src/app/(dashboard)/shopping-lists/prices/__tests__/page.test.tsx:72:11 +  70| render(<ShoppingListPricesPage />); +  71| +  72| await waitFor(() => { +  | ^ +  73| expect(screen.getByText('Analytics +failed')).toBeInTheDocument(); +  74| }); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/7]⎯ + + FAIL  src/app/(dashboard)/shopping-lists/[id]/__tests__/page.test.tsx > +ShoppingListDetailPage > loads and displays the list +TestingLibraryElementError: Found multiple elements with the text: Apple + +Here are the matching elements: + +Ignored nodes: comments, script, style + + Apple + + +Ignored nodes: comments, script, style + + Apple + + +(If this is intentional, then use the `*AllBy*` variant of the query (like `queryAllByText`, `getAllByText`, or +`findAllByText`)). + +Ignored nodes: comments, script, style + + 
 +  + Test List +  +  +  + active +  +  +  + Live Sync Channel Operational + 
 +  +  +  +  +  +  +  +  Back to Hub +  +  +  +  +  +  +  +  Check Lowest Store Options +  +  +  +  + 
 +  + 
 +  + Other / Misc +  +  +  +  +  +  + Apple + 
 +  +  + Qty:  + 5 +   + pcs +  + 
 +  +  + ... + +Ignored nodes: comments, script, style + +  +  + 
 +  + Test List +  +  +  + active +  +  +  + Live Sync Channel Operational + 
 +  +  +  +  +  +  +  +  Back to Hub +  +  +  +  +  +  +  +  Check Lowest Store Options +  +  +  +  + 
 +  + 
 +  + Other / Misc +  +  +  +  +  +  + Apple + 
 +  +  + Qty:  + 5 +   + pcs +  +  { +  65| render(<ShoppingListDetailPage />); +  66| await waitFor(() => { +  | ^ +  67| expect(screen.getByText('Test +List')).toBeInTheDocument(); +  68| expect(screen.getByText('Apple')).toBeI +nTheDocument(); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/7]⎯ + + FAIL  src/app/(dashboard)/shopping-lists/[id]/__tests__/page.test.tsx > +ShoppingListDetailPage > toggles an item check +TestingLibraryElementError: Found multiple elements with the text: Apple + +Here are the matching elements: + +Ignored nodes: comments, script, style + + Apple +
 + +Ignored nodes: comments, script, style + + Apple + + +(If this is intentional, then use the `*AllBy*` variant of the query (like `queryAllByText`, `getAllByText`, or +`findAllByText`)). + +Ignored nodes: comments, script, style + + 
 +  + Test List +  +  +  + active +  +  +  + Live Sync Channel Operational + 
 +  +  +  +  +  +  +  +  Back to Hub +  +  +  +  +  +  +  +  Check Lowest Store Options +  +  +  +  + 
 +  + 
 +  + Other / Misc +  +  +  +  +  +  + Apple + 
 +  +  + Qty:  + 5 +   + pcs +  + 
 +  +  + ... + +Ignored nodes: comments, script, style + +  +  + 
 +  + Test List +  +  +  + active +  +  +  + Live Sync Channel Operational + 
 +  +  +  +  +  +  +  +  Back to Hub +  +  +  +  +  +  +  +  Check Lowest Store Options +  +  +  +  + 
 +  + 
 +  + Other / Misc +  +  +  +  +  +  + Apple + 
 +  +  + Qty:  + 5 +   + pcs +  +  { +  73| render(<ShoppingListDetailPage />); +  74| await waitFor(() => expect(screen.getByText('Apple')).toBeInTheDoc… +  | ^ +  75| +  76| const checkbox = screen.getByRole('button', { name: /Toggle check … + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/7]⎯ + + FAIL  src/app/(dashboard)/shopping-lists/[id]/__tests__/page.test.tsx > +ShoppingListDetailPage > adds a new item to the list +TestingLibraryElementError: Found multiple elements with the text: Apple + +Here are the matching elements: + +Ignored nodes: comments, script, style + + Apple +
 + +Ignored nodes: comments, script, style + + Apple + + +(If this is intentional, then use the `*AllBy*` variant of the query (like `queryAllByText`, `getAllByText`, or +`findAllByText`)). + +Ignored nodes: comments, script, style + + 
 +  + Test List +  +  +  + active +  +  +  + Live Sync Channel Operational + 
 +  +  +  +  +  +  +  +  Back to Hub +  +  +  +  +  +  +  +  Check Lowest Store Options +  +  +  +  + 
 +  + 
 +  + Other / Misc +  +  +  +  +  +  + Apple + 
 +  +  + Qty:  + 5 +   + pcs +  + 
 +  +  + ... + +Ignored nodes: comments, script, style + +  +  + 
 +  + Test List +  +  +  + active +  +  +  + Live Sync Channel Operational + 
 +  +  +  +  +  +  +  +  Back to Hub +  +  +  +  +  +  +  +  Check Lowest Store Options +  +  +  +  + 
 +  + 
 +  + Other / Misc +  +  +  +  +  +  + Apple + 
 +  +  + Qty:  + 5 +   + pcs +  + ); +  92| await waitFor(() => expect(screen.getByText('Apple')).toBeInTheDoc… +  | ^ +  93| +  94| const customInput = screen.getByPlaceholderText(/e.g., Generic Flo… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/7]⎯ + + FAIL  src/app/(dashboard)/shopping-lists/[id]/__tests__/page.test.tsx > +ShoppingListDetailPage > completes the trip and syncs to pantry +TestingLibraryElementError: Found multiple elements with the text: Apple + +Here are the matching elements: + +Ignored nodes: comments, script, style + + Apple +
 + +Ignored nodes: comments, script, style + + Apple + + +(If this is intentional, then use the `*AllBy*` variant of the query (like `queryAllByText`, `getAllByText`, or +`findAllByText`)). + +Ignored nodes: comments, script, style + + 
 +  + Test List +  +  +  + active +  +  +  + Live Sync Channel Operational + 
 +  +  +  +  +  +  +  +  Back to Hub +  +  +  +  +  +  +  +  Check Lowest Store Options +  +  +  +  +  +  +  Sync  + 1 +  items to Pantry +  +  +  +  + 
 +  + 
 +  + Other / Misc +  +  +  +  +  + 
 +  + Test List +  +  +  + active +  +  +  + Live Sync Channel Operational + 
 + 
 +  +  +  +  +  +  +  Back to Hub +  +  +  +  +  +  +  +  Check Lowest Store Options +  +  +  +  +  +  +  Sync  + 1 +  items to Pantry +  + 
 +  +  + 
 +  + 
 +  + Other / Misc +  + ); + 124| await waitFor(() => expect(screen.getByText('Apple')).toBeInTheDoc… +  | ^ + 125| + 126| const syncBtn = screen.getByRole('button', { name: /Sync.*items to… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[7/7]⎯ + diff --git a/packages/web/test_results_utf8.txt b/packages/web/test_results_utf8.txt new file mode 100644 index 0000000..855b019 --- /dev/null +++ b/packages/web/test_results_utf8.txt @@ -0,0 +1,1639 @@ + + RUN  v4.1.2 F:/Coding/MeshiTrack/packages/web + +node.exe : stderr | src/app/(dashboard)/shopping-lists/[id]/__tests__/page.test.tsx > +ShoppingListDetailPage > loads and displays the list +At line:1 char:1 ++ & "C:\nodejs/node.exe" "C:\nodejs/node_modules/npm/bin/npx-cli.js" vi ... ++ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + CategoryInfo : NotSpecified: (stderr...splays the list:String) [], RemoteException + + FullyQualifiedErrorId : NativeCommandError + +Each child in a list should have a unique "key" prop. + +Check the render method of `ShoppingListDetailsPage`. See https://react.dev/link/warning-keys for more information. + +stderr | src/app/(dashboard)/shopping-lists/[id]/__tests__/page.test.tsx > +ShoppingListDetailPage > loads and displays the list +TypeError: fetch failed + at node:internal/deps/undici/undici:16416:13 + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at ApiClient.get (F:/Coding/MeshiTrack/packages/web/src/services/api-client.ts:46:17) { + [cause]: AggregateError: +  at internalConnectMultiple (node:net:1134:18) +  at afterConnectMultiple (node:net:1715:7) { + code: 'ECONNREFUSED', + [errors]: [ [Error], [Error] ] + } +} + + ✓ src/components/__tests__/ThemeProvider.test.tsx (8 tests) 116ms + ✓ src/app/(dashboard)/medicines/__tests__/page.test.tsx (3 tests) 123ms + ✓ src/components/__tests__/TopBar.test.tsx (4 tests) 277ms + ✓ src/app/(dashboard)/products/__tests__/ImportDialog.test.tsx (11 tests) 359ms + ✓ src/components/__tests__/ui.test.tsx (11 tests) 285ms + ✓ src/app/__tests__/page.test.tsx (2 tests) 254ms + ✓ src/components/__tests__/Sidebar.test.tsx (8 tests) 334ms +stderr | src/app/(dashboard)/dashboard/__tests__/page.test.tsx > DashboardPage > +shows "good shape" message when no critical alerts +Each child in a list should have a unique "key" prop. + +Check the render method of `DashboardPage`. See https://react.dev/link/warning-keys for more information. + + ✓ src/app/(dashboard)/medicines/schedule/__tests__/page.test.tsx (14 tests) 408ms +stderr | src/app/(dashboard)/shopping-lists/__tests__/page.test.tsx > ShoppingListsPage > +loads and displays shopping lists +Each child in a list should have a unique "key" prop. + +Check the render method of `ShoppingListsPage`. See https://react.dev/link/warning-keys for more information. + + ✓ src/app/(dashboard)/dashboard/__tests__/page.test.tsx (13 tests) 406ms + ❯ src/app/(dashboard)/shopping-lists/prices/__tests__/page.test.tsx (3 tests | 3 failed) 52ms + × renders loading initially 42ms + × loads and displays analytics 4ms + × handles fetch errors gracefully 3ms + ✓ src/app/(dashboard)/shopping-lists/__tests__/page.test.tsx (6 tests) 710ms + ✓ toggles create list modal and submits  319ms +stderr | src/app/(dashboard)/shopping-lists/[id]/__tests__/page.test.tsx > +ShoppingListDetailPage > toggles an item check +TypeError: fetch failed + at node:internal/deps/undici/undici:16416:13 + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at ApiClient.get (F:/Coding/MeshiTrack/packages/web/src/services/api-client.ts:46:17) { + [cause]: AggregateError: +  at internalConnectMultiple (node:net:1134:18) +  at afterConnectMultiple (node:net:1715:7) { + code: 'ECONNREFUSED', + [errors]: [ [Error], [Error] ] + } +} + +stderr | src/app/(dashboard)/medicine-prices/__tests__/page.test.tsx > MedicinePricesPage > +renders Medicine Prices heading when householdId exists +An update to MedicinePricesContent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at +https://react.dev/link/wrap-tests-with-act +An update to MedicinePricesContent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at +https://react.dev/link/wrap-tests-with-act + +stderr | src/app/(dashboard)/refills/__tests__/page.test.tsx > RefillsPage > +renders Refills heading when householdId exists +An update to AlertsPanel inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at +https://react.dev/link/wrap-tests-with-act +An update to AlertsPanel inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at +https://react.dev/link/wrap-tests-with-act +An update to RefillListsPanel inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at +https://react.dev/link/wrap-tests-with-act +An update to RefillListsPanel inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at +https://react.dev/link/wrap-tests-with-act + + ✓ src/app/(dashboard)/recipes/new/__tests__/page.test.tsx (3 tests) 203ms + ✓ src/app/(dashboard)/recipes/[id]/edit/__tests__/page.test.tsx (4 tests) 283ms + ✓ src/app/(dashboard)/recipes/[id]/__tests__/page.test.tsx (11 tests) 557ms + ✓ src/app/(dashboard)/purchases/__tests__/page.test.tsx (26 tests) 2317ms + ✓ src/app/(dashboard)/stores/__tests__/page.test.tsx (22 tests) 2286ms + ✓ toggles a preset tag and adds a custom tag  445ms + ✓ src/app/(dashboard)/pantry/__tests__/page.test.tsx (15 tests) 801ms + ✓ src/app/(dashboard)/recipes/__tests__/RecipeEditor.test.tsx (9 tests) 831ms + ✓ src/app/(dashboard)/recipes/__tests__/page.test.tsx (22 tests) 885ms +stderr | src/app/(dashboard)/medicines/__tests__/ActivityTab.test.tsx > ActivityTab > +loads more events when Load more is clicked +Encountered two children with the same key, `ev-1`. Keys should be unique so that components maintain their +identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — the behavior is +unsupported and could change in a future version. + +stderr | src/app/(dashboard)/shopping-lists/[id]/__tests__/page.test.tsx > +ShoppingListDetailPage > adds a new item to the list +TypeError: fetch failed + at node:internal/deps/undici/undici:16416:13 + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at ApiClient.get (F:/Coding/MeshiTrack/packages/web/src/services/api-client.ts:46:17) { + [cause]: AggregateError: +  at internalConnectMultiple (node:net:1134:18) +  at afterConnectMultiple (node:net:1715:7) { + code: 'ECONNREFUSED', + [errors]: [ [Error], [Error] ] + } +} + +stderr | src/app/(dashboard)/medicine-prices/__tests__/page.test.tsx > MedicinePricesPage > +shows Load more button in price history +Encountered two children with the same key, `pr-1`. Keys should be unique so that components maintain their +identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — the behavior is +unsupported and could change in a future version. + + ✓ src/app/(dashboard)/medicines/__tests__/ActivityTab.test.tsx (18 tests) 979ms + ✓ src/app/(dashboard)/products/__tests__/page.test.tsx (11 tests) 1234ms + ✓ filters by search input with debounce  413ms +stderr | src/app/(dashboard)/medicines/__tests__/LibraryTab.test.tsx > LibraryTab > +filters medicines by search, category, and form +An update to LibraryTab inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at +https://react.dev/link/wrap-tests-with-act +An update to LibraryTab inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at +https://react.dev/link/wrap-tests-with-act +An update to LibraryTab inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at +https://react.dev/link/wrap-tests-with-act +An update to LibraryTab inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at +https://react.dev/link/wrap-tests-with-act +An update to LibraryTab inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at +https://react.dev/link/wrap-tests-with-act + + ✓ src/app/(dashboard)/medicines/__tests__/OrganizerTab.test.tsx (20 tests) 1394ms + ✓ src/app/(dashboard)/medicine-prices/__tests__/page.test.tsx (15 tests) 1432ms +stderr | src/app/(dashboard)/medicines/__tests__/CabinetTab.test.tsx > CabinetTab > +changes quantity and unit in AddToCabinetForm +An update to AddToCabinetForm inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at +https://react.dev/link/wrap-tests-with-act + + ✓ src/app/(dashboard)/products/__tests__/ProductModal.test.tsx (18 tests) 1701ms + ✓ calls onSave with correct data on valid submit  331ms + ✓ src/app/(dashboard)/settings/__tests__/page.test.tsx (20 tests) 3677ms + ✓ allows editing the household name  438ms + ✓ shows error when name update fails  458ms + ✓ shows fallback error when non-Error thrown on name update  372ms +stderr | src/app/(dashboard)/shopping-lists/[id]/__tests__/page.test.tsx > +ShoppingListDetailPage > completes the trip and syncs to pantry +TypeError: fetch failed + at node:internal/deps/undici/undici:16416:13 + at processTicksAndRejections (node:internal/process/task_queues:103:5) + at ApiClient.get (F:/Coding/MeshiTrack/packages/web/src/services/api-client.ts:46:17) { + [cause]: AggregateError: +  at internalConnectMultiple (node:net:1134:18) +  at afterConnectMultiple (node:net:1715:7) { + code: 'ECONNREFUSED', + [errors]: [ [Error], [Error] ] + } +} + + ✓ src/app/(dashboard)/medicines/__tests__/LibraryTab.test.tsx (14 tests) 2075ms + ✓ creates medicine and refreshes list  515ms + ✓ shows fallback error when non-Error is thrown on create medicine  466ms + ❯ src/app/(dashboard)/shopping-lists/[id]/__tests__/page.test.tsx (5 tests | 5 failed) 4140ms + × renders loading initially 57ms + × loads and displays the list 1035ms + × toggles an item check 1019ms + × adds a new item to the list 1016ms + × completes the trip and syncs to pantry 1009ms + ✓ src/app/(dashboard)/refills/__tests__/page.test.tsx (26 tests) 3097ms + ✓ src/app/(dashboard)/medicines/__tests__/CabinetTab.test.tsx (29 tests) 3172ms + ✓ src/app/(dashboard)/medicines/__tests__/RegimensTab.test.tsx (32 tests) 3206ms + ✓ src/app/(dashboard)/medicines/cabinet/__tests__/page.test.tsx (3 tests) 106ms + ✓ src/app/(dashboard)/medicines/organizer/__tests__/page.test.tsx (3 tests) 108ms + ✓ src/app/(dashboard)/medicines/regimens/__tests__/page.test.tsx (3 tests) 106ms + ✓ src/app/(dashboard)/medicines/[id]/__tests__/page.test.tsx (34 tests) 3690ms + ✓ toggles Add Product form  339ms +stdout | src/lib/useShoppingListSync.test.ts > useShoppingListSync > handles incoming item_updated messages correctly +🛒 Connected to shopping list real-time sync: list1 + +stdout | src/lib/useShoppingListSync.test.ts > useShoppingListSync > broadcasts toggle item messages when connected +🛒 Connected to shopping list real-time sync: list1 + +stdout | src/lib/useShoppingListSync.test.ts > useShoppingListSync > handles disconnect and reconnect backoff +🛒 Connected to shopping list real-time sync: list1 +🔌 Sync severed: test +🔄 Attempting sync handshake reconnect (1/5)... + + ✓ src/lib/useShoppingListSync.test.ts (4 tests) 49ms + ✓ src/app/(dashboard)/__tests__/layout.test.tsx (1 test) 54ms + ✓ src/app/(dashboard)/medicines/activity/__tests__/page.test.tsx (3 tests) 94ms + ✓ src/app/(dashboard)/medicines/library/__tests__/page.test.tsx (3 tests) 98ms + ✓ src/lib/__tests__/useApi.test.ts (7 tests) 33ms + ✓ src/app/(dashboard)/__tests__/loading.test.tsx (1 test) 37ms + ✓ src/services/__tests__/api-client.test.ts (14 tests) 28ms + ✓ src/services/__tests__/recipes.test.ts (11 tests) 17ms + ✓ src/services/__tests__/products.test.ts (12 tests) 18ms + ✓ src/services/__tests__/regimens.test.ts (8 tests) 13ms + ✓ src/services/__tests__/refills.test.ts (9 tests) 11ms + ✓ src/services/meal-plans.test.ts (9 tests) 12ms + ✓ src/services/__tests__/organizer.test.ts (7 tests) 8ms + ✓ src/services/__tests__/stores.test.ts (6 tests) 9ms + ✓ src/services/shopping-lists.test.ts (12 tests) 12ms + ✓ src/services/__tests__/pantry.test.ts (13 tests) 16ms + ✓ src/services/__tests__/cabinet.test.ts (11 tests) 14ms + ✓ src/services/__tests__/households.test.ts (5 tests) 9ms + ✓ src/services/prices.test.ts (5 tests) 10ms + ✓ src/services/__tests__/medicines.test.ts (12 tests) 14ms + ✓ src/services/nutrition-targets.test.ts (4 tests) 8ms + ✓ src/services/__tests__/purchases.test.ts (7 tests) 8ms + ✓ src/services/__tests__/cabinet-events.test.ts (7 tests) 13ms + ✓ src/services/__tests__/medicine-prices.test.ts (7 tests) 10ms + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 8 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL  src/app/(dashboard)/shopping-lists/prices/__tests__/page.test.tsx > +ShoppingListPricesPage > renders loading initially + FAIL  src/app/(dashboard)/shopping-lists/prices/__tests__/page.test.tsx > +ShoppingListPricesPage > loads and displays analytics + FAIL  src/app/(dashboard)/shopping-lists/prices/__tests__/page.test.tsx > +ShoppingListPricesPage > handles fetch errors gracefully +Error: invariant expected app router to be mounted + ❯ useRouter ../../node_modules/next/src/client/components/navigation.ts:179:10 + ❯ PricesAnalyticsPage src/app/(dashboard)/shopping-lists/prices/page.tsx:25:18 +  23| export default function PricesAnalyticsPage() { +  24| const { householdId, isLoading } = useApi(); +  25| const router = useRouter(); +  | ^ +  26| const [data, setData] = +useState<any>(null); +  27| const [loading, setLoading] = +useState(true); + ❯ Object.react_stack_bottom_frame +../../node_modules/react-dom/cjs/react-dom-client.development.js:25904:20 + ❯ renderWithHooks +../../node_modules/react-dom/cjs/react-dom-client.development.js:7662:22 + ❯ updateFunctionComponent +../../node_modules/react-dom/cjs/react-dom-client.development.js:10166:19 + ❯ beginWork ../../node_modules/react-dom/cjs/react-dom-client.development.js:11778:18 + ❯ runWithFiberInDEV +../../node_modules/react-dom/cjs/react-dom-client.development.js:874:13 + ❯ performUnitOfWork +../../node_modules/react-dom/cjs/react-dom-client.development.js:17641:22 + ❯ workLoopSync ../../node_modules/react-dom/cjs/react-dom-client.development.js:17469:41 + ❯ renderRootSync +../../node_modules/react-dom/cjs/react-dom-client.development.js:17450:11 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/8]⎯ + + FAIL  src/app/(dashboard)/shopping-lists/[id]/__tests__/page.test.tsx > +ShoppingListDetailPage > renders loading initially +TestingLibraryElementError: Unable to find an element with the text: Shopping List. This could be +because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to +make your matcher more flexible. + +Ignored nodes: comments, script, style + + 
 +  + Hydrating session checklist... + 
 + 
 + + ❯ Object.getElementError ../../node_modules/@testing-library/dom/dist/config.js:37:19 + ❯ ../../node_modules/@testing-library/dom/dist/query-helpers.js:76:38 + ❯ ../../node_modules/@testing-library/dom/dist/query-helpers.js:52:17 + ❯ ../../node_modules/@testing-library/dom/dist/query-helpers.js:95:19 + ❯ src/app/(dashboard)/shopping-lists/[id]/__tests__/page.test.tsx:48:19 +  46| vi.mocked(useApiModule.useApi).mockReturnValue({ householdId: null… +  47| render(<ShoppingListDetailPage />); +  48| expect(screen.getByText('Shopping +List')).toBeInTheDocument(); +  | ^ +  49| }); +  50| + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/8]⎯ + + FAIL  src/app/(dashboard)/shopping-lists/[id]/__tests__/page.test.tsx > +ShoppingListDetailPage > loads and displays the list +TestingLibraryElementError: Unable to find an element with the text: Apple. This could be because +the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make +your matcher more flexible. + +Ignored nodes: comments, script, style + + 
 +  + Test List +  +  +  + active +  +  +  + Live Sync Channel Operational + 
 + 
 +  +  +  +  +  +  +  Back to Hub +  +  +  +  +  +  +  +  Check Lowest Store Options +  +  +  +  + 
 +  + 
 +  + Other / Misc +  +  +  +  +  +  + Ingredient Loading... + 
 +  +  + Qty:  + 5 +   +  + 
 +  +  +  +  +  + 
 +  + Test List +  +  +  + active +  +  +  + Live Sync Channel Operational + 
 +  +  +  +  +  +  +  +  Back to Hub +  +  +  +  +  +  +  +  Check Lowest Store Options +  +  +  +  + 
 +  + 
 +  + Other / Misc +  +  +  +  +  +  + Ingredient Loading... + 
 +  +  + Qty:  + 5 +   +  + 
 +  +  { +  52| render(<ShoppingListDetailPage />); +  53| await waitFor(() => { +  | ^ +  54| expect(screen.getByText('Test +List')).toBeInTheDocument(); +  55| expect(screen.getByText('Apple')).toBeI +nTheDocument(); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/8]⎯ + + FAIL  src/app/(dashboard)/shopping-lists/[id]/__tests__/page.test.tsx > +ShoppingListDetailPage > toggles an item check +TestingLibraryElementError: Unable to find an element with the text: Apple. This could be because +the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make +your matcher more flexible. + +Ignored nodes: comments, script, style + + 
 +  + Test List +  +  +  + active +  +  +  + Live Sync Channel Operational + 
 +  +  +  +  +  +  +  +  Back to Hub +  +  +  +  +  +  +  +  Check Lowest Store Options +  +  +  +  + 
 +  + 
 +  + Other / Misc +  +  +  +  +  +  + Ingredient Loading... + 
 +  +  + Qty:  + 5 +   +  + 
 +  +  +  +  +  + 
 +  + Test List +  +  +  + active +  +  +  + Live Sync Channel Operational + 
 +  +  +  +  +  +  +  +  Back to Hub +  +  +  +  +  +  +  +  Check Lowest Store Options +  +  +  +  + 
 +  + 
 +  + Other / Misc +  +  +  +  +  +  + Ingredient Loading... + 
 +  +  + Qty:  + 5 +   +  + 
 +  +  { +  60| render(<ShoppingListDetailPage />); +  61| await waitFor(() => expect(screen.getByText('Apple')).toBeInTheDoc… +  | ^ +  62| +  63| const checkboxes = +screen.getAllByRole('checkbox'); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/8]⎯ + + FAIL  src/app/(dashboard)/shopping-lists/[id]/__tests__/page.test.tsx > +ShoppingListDetailPage > adds a new item to the list +TestingLibraryElementError: Unable to find an element with the text: Apple. This could be because +the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make +your matcher more flexible. + +Ignored nodes: comments, script, style + + 
 +  + Test List +  +  +  + active +  +  +  + Live Sync Channel Operational + 
 +  +  +  +  +  +  +  +  Back to Hub +  +  +  +  +  +  +  +  Check Lowest Store Options +  +  +  +  + 
 +  + 
 +  + Other / Misc +  +  +  +  +  +  + Ingredient Loading... + 
 +  +  + Qty:  + 5 +   +  + 
 +  +  +  +  +  + 
 +  + Test List +  +  +  + active +  +  +  + Live Sync Channel Operational + 
 +  +  +  +  +  +  +  +  Back to Hub +  +  +  +  +  +  +  +  Check Lowest Store Options +  +  +  +  + 
 +  + 
 +  + Other / Misc +  +  +  +  +  +  + Ingredient Loading... + 
 +  +  + Qty:  + 5 +   +  + 
 +  + ); +  80| await waitFor(() => expect(screen.getByText('Apple')).toBeInTheDoc… +  | ^ +  81| +  82| const toggleBtn = screen.getByRole('button', { name: /Add Product/… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/8]⎯ + + FAIL  src/app/(dashboard)/shopping-lists/[id]/__tests__/page.test.tsx > +ShoppingListDetailPage > completes the trip and syncs to pantry +TestingLibraryElementError: Unable to find an element with the text: Apple. This could be because +the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make +your matcher more flexible. + +Ignored nodes: comments, script, style + + 
 +  + Test List +  +  +  + active +  +  +  + Live Sync Channel Operational + 
 +  +  +  +  +  +  +  +  Back to Hub +  +  +  +  +  +  +  +  Check Lowest Store Options +  +  +  +  + 
 +  + 
 +  + Other / Misc +  +  +  +  +  +  + Ingredient Loading... + 
 +  +  + Qty:  + 5 +   +  + 
 +  +  +  +  +  + 
 +  + Test List +  +  +  + active +  +  +  + Live Sync Channel Operational + 
 +  +  +  +  +  +  +  +  Back to Hub +  +  +  +  +  +  +  +  Check Lowest Store Options +  +  +  +  + 
 +  + 
 +  + Other / Misc +  +  +  +  +  +  + Ingredient Loading... + 
 +  +  + Qty:  + 5 +   +  + 
 +  + ); +  91| await waitFor(() => expect(screen.getByText('Apple')).toBeInTheDoc… +  | ^ +  92| +  93| const finishBtn = screen.getByRole('button', { name: /Finish Trip/… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/8]⎯ +