From 245520fb500f93aec807d0c350d8646d70730d9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aerilyn=20Dziedzel=C4=97?= Date: Tue, 19 May 2026 10:13:40 +0900 Subject: [PATCH] Cleanup after initial plan --- .agents/skills/grill-with-docs/SKILL.md | 88 + ANTIGRAVITY.md | 9 +- .../src/modules/cabinet/cabinet.repository.ts | 4 +- .../meal-plans/meal-plans.repository.test.ts | 55 + .../meal-plans/meal-plans.routes.test.ts | 84 + .../meal-plans/meal-plans.service.test.ts | 24 + .../modules/meal-plans/meal-plans.service.ts | 22 +- .../meal-plans/shopping-gap.service.test.ts | 124 ++ .../suggestion-engine.service.test.ts | 107 + .../nutrition-target.repository.test.ts | 15 + .../nutrition-target.routes.test.ts | 16 +- .../src/modules/pantry/pantry.repository.ts | 1 + .../src/modules/prices/prices.routes.test.ts | 38 +- .../src/modules/prices/prices.service.test.ts | 73 + .../modules/regimens/regimens.repository.ts | 3 +- .../src/modules/regimens/regimens.service.ts | 6 +- .../shopping-lists.repository.test.ts | 77 +- .../shopping-lists.repository.ts | 41 +- .../shopping-lists.routes.test.ts | 114 + .../shopping-lists/shopping-lists.routes.ts | 9 +- .../shopping-lists.service.test.ts | 268 +++ .../shopping-lists/shopping-lists.service.ts | 8 +- .../api/src/schemas/shopping-list.schema.ts | 14 +- .../dashboard/__tests__/page.test.tsx | 60 +- .../src/app/(dashboard)/dashboard/page.tsx | 96 +- .../app/(dashboard)/medicines/CabinetTab.tsx | 170 +- .../app/(dashboard)/medicines/RegimensTab.tsx | 104 +- .../medicines/__tests__/CabinetTab.test.tsx | 69 +- .../medicines/__tests__/RegimensTab.test.tsx | 71 +- .../src/app/(dashboard)/pantry/PantryList.tsx | 71 +- .../pantry/__tests__/page.test.tsx | 31 +- .../products/__tests__/page.test.tsx | 116 +- .../recipes/[id]/__tests__/page.test.tsx | 62 +- .../recipes/__tests__/RecipeEditor.test.tsx | 33 + .../[id]/__tests__/page.test.tsx | 267 +++ .../(dashboard)/shopping-lists/[id]/page.tsx | 95 +- .../shopping-lists/__tests__/page.test.tsx | 220 ++ .../prices/__tests__/page.test.tsx | 101 + packages/web/src/components/ThemeProvider.tsx | 35 +- .../__tests__/ThemeProvider.test.tsx | 2 +- .../web/src/components/__tests__/ui.test.tsx | 34 + .../__tests__/PageHeaderContext.test.tsx | 34 + .../src/components/ui/__tests__/index.test.ts | 16 + .../web/src/lib/useShoppingListSync.test.ts | 107 + packages/web/src/services/meal-plans.test.ts | 68 + .../src/services/nutrition-targets.test.ts | 37 + packages/web/src/services/prices.test.ts | 54 + .../web/src/services/shopping-lists.test.ts | 87 + packages/web/src/styles/globals.css | 584 +++-- packages/web/test_results.txt | Bin 0 -> 175874 bytes packages/web/test_results_2.txt | Bin 0 -> 194896 bytes packages/web/test_results_2_utf8.txt | 1891 +++++++++++++++++ packages/web/test_results_utf8.txt | 1639 ++++++++++++++ 53 files changed, 6733 insertions(+), 621 deletions(-) create mode 100644 .agents/skills/grill-with-docs/SKILL.md create mode 100644 packages/web/src/app/(dashboard)/shopping-lists/[id]/__tests__/page.test.tsx create mode 100644 packages/web/src/app/(dashboard)/shopping-lists/__tests__/page.test.tsx create mode 100644 packages/web/src/app/(dashboard)/shopping-lists/prices/__tests__/page.test.tsx create mode 100644 packages/web/src/components/layout/__tests__/PageHeaderContext.test.tsx create mode 100644 packages/web/src/components/ui/__tests__/index.test.ts create mode 100644 packages/web/src/lib/useShoppingListSync.test.ts create mode 100644 packages/web/src/services/meal-plans.test.ts create mode 100644 packages/web/src/services/nutrition-targets.test.ts create mode 100644 packages/web/src/services/prices.test.ts create mode 100644 packages/web/src/services/shopping-lists.test.ts create mode 100644 packages/web/test_results.txt create mode 100644 packages/web/test_results_2.txt create mode 100644 packages/web/test_results_2_utf8.txt create mode 100644 packages/web/test_results_utf8.txt 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 0000000000000000000000000000000000000000..da2313527d7f3a509025ce006685201013284b7c GIT binary patch literal 175874 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
  • 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 literal 0 HcmV?d00001 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]⎯ +