diff --git a/.antigravity/settings.local.json b/.antigravity/settings.local.json index 62de4bd..5491172 100644 --- a/.antigravity/settings.local.json +++ b/.antigravity/settings.local.json @@ -1,29 +1,18 @@ { "permissions": { "allow": [ - "run_command(npm run *)", - "run_command(npm test *)", - "run_command(npm install)", - "view_file(*)", - "list_dir(*)", - "grep_search(*)", - "replace_file_content(*)", - "multi_replace_file_content(*)", - "write_to_file(*)", - "command_status(*)" + "command(npm run)", + "command(npm test)", + "command(npm install)", + "write_file(f:/Coding/MeshiTrack)" ], "deny": [ - "run_command(grep *)", - "run_command(ls *)", - "run_command(cat *)", - "run_command(head *)", - "run_command(tail *)", - "run_command(npx *)", - "run_command(*|*)", - "run_command(*>*)", - "run_command(*&*)", - "run_command(*Select-Object*)", - "run_command(*Select-String*)" + "command(grep)", + "command(ls)", + "command(cat)", + "command(head)", + "command(tail)", + "command(npx)" ] } } diff --git a/packages/api/src/main.ts b/packages/api/src/main.ts index 0e8a56a..302820c 100644 --- a/packages/api/src/main.ts +++ b/packages/api/src/main.ts @@ -42,6 +42,8 @@ import recipesRoutes from './modules/recipes/recipes.routes.js'; import pantryRoutes from './modules/pantry/pantry.routes.js'; import freshnessRulesRoutes from './modules/freshness-rules/freshness-rules.routes.js'; import productsRoutes from './modules/products/products.routes.js'; +import mealPlansRoutes from './modules/meal-plans/meal-plans.routes.js'; +import nutritionTargetsRoutes from './modules/nutrition-targets/nutrition-target.routes.js'; export async function buildApp(opts: { logger?: boolean | object } = {}) { const app = Fastify({ @@ -125,6 +127,8 @@ export async function buildApp(opts: { logger?: boolean | object } = {}) { await app.register(recipesRoutes); await app.register(pantryRoutes); await app.register(freshnessRulesRoutes); + await app.register(mealPlansRoutes); + await app.register(nutritionTargetsRoutes); // Global error handler app.setErrorHandler((error, request, reply) => { 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 new file mode 100644 index 0000000..3a2a474 --- /dev/null +++ b/packages/api/src/modules/meal-plans/meal-plans.repository.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MealPlanRepository } from './meal-plans.repository.js'; +import { MealPlanStatus } from '@meshitrack/shared'; + +const { mockSave, MockMealPlanModel } = vi.hoisted(() => { + const mockSave = vi.fn(); + function MockModel(this: { save: typeof mockSave }, data: unknown) { + Object.assign(this, data); + this.save = mockSave; + } + Object.assign(MockModel, { + findOne: vi.fn(), + find: vi.fn(), + findOneAndUpdate: vi.fn(), + findOneAndDelete: vi.fn(), + }); + return { mockSave, MockMealPlanModel: MockModel }; +}); + +vi.mock('../../schemas/meal-plan.schema.js', () => ({ + MealPlanModel: MockMealPlanModel, +})); + +const { MealPlanModel } = await import('../../schemas/meal-plan.schema.js'); + +function makeChain(result: unknown = null) { + return { + sort: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + lean: vi.fn().mockReturnThis(), + exec: vi.fn().mockResolvedValue(result), + }; +} + +describe(MealPlanRepository.name, () => { + let repo: MealPlanRepository; + + beforeEach(() => { + vi.clearAllMocks(); + repo = new MealPlanRepository(); + }); + + describe('findByHousehold', () => { + it('applies householdId filter', async () => { + const chain = makeChain([]); + vi.mocked(MealPlanModel.find).mockReturnValue(chain as never); + + await repo.findByHousehold('hh1', { limit: 20 }); + + expect(MealPlanModel.find).toHaveBeenCalledWith( + expect.objectContaining({ householdId: 'hh1' }), + ); + }); + }); + + describe('findByWeek', () => { + it('queries by householdId and weekStartDate', async () => { + const mockPlan = { _id: 'mp1', weekStartDate: '2026-05-18' }; + vi.mocked(MealPlanModel.findOne).mockReturnValue(makeChain(mockPlan) as never); + + const result = await repo.findByWeek('hh1', '2026-05-18'); + expect(MealPlanModel.findOne).toHaveBeenCalledWith({ + householdId: 'hh1', + weekStartDate: '2026-05-18', + }); + expect(result).toEqual(mockPlan); + }); + }); + + describe('create', () => { + it('saves and returns new document', async () => { + const plainDoc = { _id: 'new-id', weekStartDate: '2026-05-18' }; + mockSave.mockResolvedValue({ toObject: () => plainDoc }); + + const result = await repo.create({ weekStartDate: '2026-05-18' }); + expect(mockSave).toHaveBeenCalled(); + expect(result).toEqual(plainDoc); + }); + }); + + describe('updateStatus', () => { + it('updates status only', async () => { + vi.mocked(MealPlanModel.findOneAndUpdate).mockReturnValue(makeChain({ _id: 'mp1' }) as never); + + await repo.updateStatus('mp1', 'hh1', MealPlanStatus.ACTIVE); + expect(MealPlanModel.findOneAndUpdate).toHaveBeenCalledWith( + { _id: 'mp1', householdId: 'hh1' }, + { $set: { status: MealPlanStatus.ACTIVE } }, + { new: true, lean: true }, + ); + }); + }); +}); diff --git a/packages/api/src/modules/meal-plans/meal-plans.repository.ts b/packages/api/src/modules/meal-plans/meal-plans.repository.ts new file mode 100644 index 0000000..de135e2 --- /dev/null +++ b/packages/api/src/modules/meal-plans/meal-plans.repository.ts @@ -0,0 +1,63 @@ +import { MealPlanModel } from '../../schemas/meal-plan.schema.js'; +import type { MealPlanQueryInput, MealPlanStatus } from '@meshitrack/shared'; + +export class MealPlanRepository { + public async findByHousehold(householdId: string, query: MealPlanQueryInput) { + const filter: Record = { householdId }; + + if (query.cursor) { + const id = Buffer.from(query.cursor, 'base64').toString(); + filter['_id'] = { $gt: id }; + } + + const limit = query.limit; + const items = await MealPlanModel.find(filter) + .sort({ _id: 1 }) + .limit(limit + 1) + .lean() + .exec(); + + const hasMore = items.length > limit; + const data = hasMore ? items.slice(0, limit) : items; + const cursor = + data.length > 0 + ? Buffer.from(data[data.length - 1]!._id.toString()).toString('base64') + : null; + + return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } }; + } + + public async findById(id: string, householdId: string) { + return MealPlanModel.findOne({ _id: id, householdId }).lean().exec(); + } + + public async findByWeek(householdId: string, weekStartDate: string) { + return MealPlanModel.findOne({ householdId, weekStartDate }).lean().exec(); + } + + public async create(data: Record) { + const doc = new MealPlanModel(data); + const saved = await doc.save(); + return saved.toObject(); + } + + public async update(id: string, householdId: string, data: Record) { + return MealPlanModel.findOneAndUpdate( + { _id: id, householdId }, + { $set: data }, + { new: true, lean: true }, + ).exec(); + } + + public async updateStatus(id: string, householdId: string, status: MealPlanStatus) { + return MealPlanModel.findOneAndUpdate( + { _id: id, householdId }, + { $set: { status } }, + { new: true, lean: true }, + ).exec(); + } + + public async delete(id: string, householdId: string) { + return MealPlanModel.findOneAndDelete({ _id: id, householdId }).lean().exec(); + } +} 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 new file mode 100644 index 0000000..b658fe9 --- /dev/null +++ b/packages/api/src/modules/meal-plans/meal-plans.routes.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import Fastify from 'fastify'; +import { fastifyAwilixPlugin } from '@fastify/awilix'; +import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'; +import { MealPlanStatus } from '@meshitrack/shared'; + +vi.mock('jose', () => ({ + createRemoteJWKSet: vi.fn(() => 'mock-jwks'), + jwtVerify: vi.fn().mockResolvedValue({ + payload: { + sub: 'kc-1', + email: 'test@example.com', + preferred_username: 'testuser', + realm_access: { roles: ['member'] }, + householdIds: ['hh1'], + }, + protectedHeader: { alg: 'RS256' }, + key: {}, + }), +})); + +const { + mockFindByHousehold, + mockFindById, + mockFindByWeek, + mockCreate, + mockUpdate, + mockUpdateStatus, + mockDelete, +} = vi.hoisted(() => ({ + mockFindByHousehold: vi.fn(), + mockFindById: vi.fn(), + mockFindByWeek: vi.fn(), + mockCreate: vi.fn(), + mockUpdate: vi.fn(), + mockUpdateStatus: vi.fn(), + mockDelete: vi.fn(), +})); + +vi.mock('./meal-plans.repository.js', () => ({ + MealPlanRepository: class { + findByHousehold = mockFindByHousehold; + findById = mockFindById; + findByWeek = mockFindByWeek; + create = mockCreate; + update = mockUpdate; + updateStatus = mockUpdateStatus; + delete = mockDelete; + }, +})); + +// Mock prerequisite repositories to allow SuggestionEngine/Gap to resolve +vi.mock('../recipes/recipes.repository.js', () => ({ + RecipesRepository: class { + findByHousehold = vi.fn().mockResolvedValue({ data: [] }); + findById = vi.fn(); + }, +})); + +vi.mock('../pantry/pantry.repository.js', () => ({ + PantryRepository: class { + findActiveByHousehold = vi.fn().mockResolvedValue([]); + }, +})); + +vi.mock('../nutrition-targets/nutrition-target.repository.js', () => ({ + NutritionTargetRepository: class { + findByUser = vi.fn().mockResolvedValue(null); + }, +})); + +vi.mock('../products/products.repository.js', () => ({ + ProductsRepository: class { + findByIds = vi.fn().mockResolvedValue([]); + }, +})); + +vi.mock('../users/users.repository.js', () => ({ + UsersRepository: class { + findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] }); + upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] }); + }, +})); + +import authPlugin from '../../plugins/auth.plugin.js'; +import householdPlugin from '../../plugins/household.plugin.js'; +import usersRoutes from '../users/users.routes.js'; +import mealPlanRoutes from './meal-plans.routes.js'; + +const emptyNutrition = { calories: 0, protein: 0, carbs: 0, fat: 0, fiber: 0, sugar: 0, sodium: 0, saturatedFat: 0, cholesterol: 0 }; + +function makePlan(overrides = {}) { + return { + _id: 'plan-1', + householdId: 'hh1', + weekStartDate: '2026-05-10', + days: Array.from({ length: 7 }, (_, i) => ({ + date: `2026-05-${10 + i}`, + meals: [], + dailyNutritionTotal: emptyNutrition, + })), + status: MealPlanStatus.DRAFT, + createdBy: 'kc-1', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + }; +} + +describe('meal-plan.routes', () => { + let app: any; + + async function buildTestApp() { + const instance = Fastify({ logger: false }); + instance.setValidatorCompiler(validatorCompiler); + instance.setSerializerCompiler(serializerCompiler); + await instance.register(fastifyAwilixPlugin, { + disposeOnClose: true, + disposeOnResponse: true, + strictBooleanEnforced: true, + }); + await instance.register(authPlugin); + await instance.register(householdPlugin); + await instance.register(usersRoutes); + await instance.register(mealPlanRoutes); + await instance.ready(); + return instance; + } + + const authHeaders = { authorization: 'Bearer valid-token' }; + + beforeEach(async () => { + vi.clearAllMocks(); + app = await buildTestApp(); + }); + + afterEach(async () => { + if (app) await app.close(); + }); + + describe('GET /api/v1/households/:householdId/meal-plans', () => { + it('returns paginated results', async () => { + mockFindByHousehold.mockResolvedValue({ + data: [makePlan()], + pagination: { cursor: null, hasMore: false }, + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/meal-plans', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.data).toHaveLength(1); + expect(body.data[0]._id).toBe('plan-1'); + }); + }); + + describe('GET /api/v1/households/:householdId/meal-plans/week/:weekStartDate', () => { + it('returns matched weekly plan', async () => { + mockFindByWeek.mockResolvedValue(makePlan()); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/meal-plans/week/2026-05-10', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()._id).toBe('plan-1'); + }); + + it('returns not-found message structure if missing', async () => { + mockFindByWeek.mockResolvedValue(null); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/meal-plans/week/2026-05-10', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().message).toBe('No meal plan scheduled for this week'); + }); + }); + + describe('POST /api/v1/households/:householdId/meal-plans', () => { + it('creates a new plan', async () => { + mockFindByWeek.mockResolvedValue(null); + mockCreate.mockImplementation((data) => Promise.resolve({ ...data, _id: 'new-plan-id', createdAt: new Date(), updatedAt: new Date() })); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/households/hh1/meal-plans', + headers: { ...authHeaders, 'content-type': 'application/json' }, + body: JSON.stringify({ + weekStartDate: '2026-05-10', + days: Array.from({ length: 7 }, (_, i) => ({ + date: `2026-05-${10 + i}`, + meals: [], + dailyNutritionTotal: emptyNutrition, + })), + status: 'draft', + }), + }); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body._id).toBe('new-plan-id'); + expect(body.status).toBe('draft'); + }); + }); + + describe('GET /api/v1/households/:householdId/meal-plans/suggestions', () => { + it('returns list of scored recipe recommendations', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/meal-plans/suggestions?limit=2', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(Array.isArray(body)).toBe(true); + }); + }); + + describe('GET /api/v1/households/:householdId/meal-plans/:id/gap', () => { + it('returns missing elements report', async () => { + mockFindById.mockResolvedValue(makePlan()); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/meal-plans/plan-1/gap', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.mealPlanId).toBe('plan-1'); + expect(Array.isArray(body.missingItems)).toBe(true); + }); + }); +}); diff --git a/packages/api/src/modules/meal-plans/meal-plans.routes.ts b/packages/api/src/modules/meal-plans/meal-plans.routes.ts new file mode 100644 index 0000000..ab85735 --- /dev/null +++ b/packages/api/src/modules/meal-plans/meal-plans.routes.ts @@ -0,0 +1,356 @@ +import fp from 'fastify-plugin'; +import { asClass, Lifetime } from 'awilix'; +import type { ZodTypeProvider } from 'fastify-type-provider-zod'; +import { z } from 'zod/v4'; +import { + CreateMealPlanSchema, + UpdateMealPlanSchema, + MealPlanQuerySchema, + MealPlanResponseSchema, + MealPlanListResponseSchema, + MealPlanStatus, +} from '@meshitrack/shared'; +import { MealPlanRepository } from './meal-plans.repository.js'; +import { MealPlanService } from './meal-plans.service.js'; +import { SuggestionEngineService } from './suggestion-engine.service.js'; +import { ShoppingGapService } from './shopping-gap.service.js'; +import { RecipesRepository } from '../recipes/recipes.repository.js'; +import { PantryRepository } from '../pantry/pantry.repository.js'; +import { NutritionTargetRepository } from '../nutrition-targets/nutrition-target.repository.js'; +import { ProductsRepository } from '../products/products.repository.js'; + +type AnyNutritionInfo = { + calories: number; + protein: number; + carbs: number; + fat: number; + fiber?: number | null; + sugar?: number | null; + sodium?: number | null; + saturatedFat?: number | null; + cholesterol?: number | null; +}; + +type AnyMealDoc = { + id: string; + type: string; + recipeId?: any; + recipeName: string; + servings: number; + customName?: string | null; + customNutrition?: AnyNutritionInfo | null; + perServingNutrition: AnyNutritionInfo; + notes?: string | null; +}; + +type AnyDayDoc = { + date: string; + meals: AnyMealDoc[]; + dailyNutritionTotal: AnyNutritionInfo; +}; + +type AnyPlanDoc = { + _id: string | { toString: () => string }; + householdId: string; + weekStartDate: string; + days: AnyDayDoc[]; + status: string; + shoppingListId?: string | null; + createdBy: string; + createdAt: string | Date; + updatedAt: string | Date; +}; + +function toStr(v: string | { toString: () => string }): string { + return typeof v === 'string' ? v : v.toString(); +} + +function toIso(v: string | Date): string { + return typeof v === 'string' ? v : v.toISOString(); +} + +function cleanNutrition(n: AnyNutritionInfo) { + return { + calories: n.calories, + protein: n.protein, + carbs: n.carbs, + fat: n.fat, + ...(n.fiber != null ? { fiber: n.fiber } : {}), + ...(n.sugar != null ? { sugar: n.sugar } : {}), + ...(n.sodium != null ? { sodium: n.sodium } : {}), + ...(n.saturatedFat != null ? { saturatedFat: n.saturatedFat } : {}), + ...(n.cholesterol != null ? { cholesterol: n.cholesterol } : {}), + }; +} + +function toPlanResponse(doc: AnyPlanDoc): z.infer { + return { + _id: toStr(doc._id), + householdId: doc.householdId, + weekStartDate: doc.weekStartDate, + days: doc.days.map((day) => ({ + date: day.date, + meals: day.meals.map((meal) => ({ + id: meal.id, + type: meal.type as any, + ...(meal.recipeId ? { recipeId: toStr(meal.recipeId) } : {}), + recipeName: meal.recipeName, + servings: meal.servings, + ...(meal.customName ? { customName: meal.customName } : {}), + ...(meal.customNutrition ? { customNutrition: cleanNutrition(meal.customNutrition) } : {}), + perServingNutrition: cleanNutrition(meal.perServingNutrition), + ...(meal.notes ? { notes: meal.notes } : {}), + })), + dailyNutritionTotal: cleanNutrition(day.dailyNutritionTotal), + })), + status: doc.status as MealPlanStatus, + ...(doc.shoppingListId ? { shoppingListId: doc.shoppingListId } : {}), + createdBy: doc.createdBy, + createdAt: toIso(doc.createdAt), + updatedAt: toIso(doc.updatedAt), + }; +} + +declare module '@fastify/awilix' { + interface Cradle { + mealPlanRepository: MealPlanRepository; + mealPlanService: MealPlanService; + suggestionEngineService: SuggestionEngineService; + shoppingGapService: ShoppingGapService; + } +} + +export default fp( + async (fastify) => { + // Ensure prerequisite repositories from other modules are registered if not already done + // Fastify modules run sequentially, but it's safe to register singletons if they're missing in Awilix. + fastify.diContainer.register({ + mealPlanRepository: asClass(MealPlanRepository, { lifetime: Lifetime.SINGLETON }), + mealPlanService: asClass(MealPlanService, { lifetime: Lifetime.SINGLETON }), + suggestionEngineService: asClass(SuggestionEngineService, { lifetime: Lifetime.SINGLETON }), + shoppingGapService: asClass(ShoppingGapService, { lifetime: Lifetime.SINGLETON }), + + // Make sure other module deps are available for classes instantiated by SuggestionEngine/ShoppingGap + recipesRepository: asClass(RecipesRepository, { lifetime: Lifetime.SINGLETON }), + pantryRepository: asClass(PantryRepository, { lifetime: Lifetime.SINGLETON }), + nutritionTargetRepository: asClass(NutritionTargetRepository, { lifetime: Lifetime.SINGLETON }), + productsRepository: asClass(ProductsRepository, { lifetime: Lifetime.SINGLETON }), + }); + + const app = fastify.withTypeProvider(); + const householdParams = z.object({ householdId: z.string() }); + + // GET /api/v1/households/:householdId/meal-plans — query/paginate list + app.route({ + method: 'GET', + url: '/api/v1/households/:householdId/meal-plans', + schema: { + params: householdParams, + querystring: MealPlanQuerySchema, + response: { 200: MealPlanListResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('mealPlanService'); + const result = await service.list(request.params.householdId, request.query); + return reply.send({ + data: result.data.map(toPlanResponse), + pagination: result.pagination, + }); + }, + }); + + // GET /api/v1/households/:householdId/meal-plans/suggestions — get algorithmic recommendations + app.route({ + method: 'GET', + url: '/api/v1/households/:householdId/meal-plans/suggestions', + schema: { + params: householdParams, + querystring: z.object({ + limit: z.coerce.number().int().min(1).max(50).default(5), + }), + response: { + 200: z.array( + z.object({ + recipeId: z.string(), + recipeName: z.string(), + totalScore: z.number(), + scores: z.object({ + coverage: z.number(), + urgency: z.number(), + nutrition: z.number(), + variety: z.number(), + }), + reasoning: z.array(z.string()), + }) + ), + }, + }, + handler: async (request, reply) => { + const engine = fastify.diContainer.resolve('suggestionEngineService'); + const suggestions = await engine.getSuggestions( + request.params.householdId, + request.user.keycloakId, + { limit: request.query.limit } + ); + return reply.send(suggestions); + }, + }); + + // GET /api/v1/households/:householdId/meal-plans/week/:weekStartDate — fetch exactly by week start date + app.route({ + method: 'GET', + url: '/api/v1/households/:householdId/meal-plans/week/:weekStartDate', + schema: { + params: householdParams.extend({ + weekStartDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + }), + response: { + 200: z.union([ + MealPlanResponseSchema, + z.object({ message: z.literal('No meal plan scheduled for this week') }), + ]), + }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('mealPlanService'); + const plan = await service.getByWeek( + request.params.householdId, + request.params.weekStartDate + ); + if (!plan) { + return reply.status(200).send({ message: 'No meal plan scheduled for this week' }); + } + return reply.send(toPlanResponse(plan as AnyPlanDoc)); + }, + }); + + // GET /api/v1/households/:householdId/meal-plans/:id — fetch by ID + app.route({ + method: 'GET', + url: '/api/v1/households/:householdId/meal-plans/:id', + schema: { + params: householdParams.extend({ id: z.string() }), + response: { 200: MealPlanResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('mealPlanService'); + const plan = await service.getById(request.params.id, request.params.householdId); + return reply.send(toPlanResponse(plan as AnyPlanDoc)); + }, + }); + + // POST /api/v1/households/:householdId/meal-plans — create a new weekly plan + app.route({ + method: 'POST', + url: '/api/v1/households/:householdId/meal-plans', + schema: { + params: householdParams, + body: CreateMealPlanSchema, + response: { 201: MealPlanResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('mealPlanService'); + const plan = await service.create( + request.params.householdId, + request.user.keycloakId, + request.body + ); + return reply.status(201).send(toPlanResponse(plan as AnyPlanDoc)); + }, + }); + + // PATCH /api/v1/households/:householdId/meal-plans/:id — granular structural updates + app.route({ + method: 'PATCH', + url: '/api/v1/households/:householdId/meal-plans/:id', + schema: { + params: householdParams.extend({ id: z.string() }), + body: UpdateMealPlanSchema, + response: { 200: MealPlanResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('mealPlanService'); + const plan = await service.update( + request.params.id, + request.params.householdId, + request.body + ); + return reply.send(toPlanResponse(plan as AnyPlanDoc)); + }, + }); + + // PATCH /api/v1/households/:householdId/meal-plans/:id/status — simple status transition (draft -> active -> archived) + app.route({ + method: 'PATCH', + url: '/api/v1/households/:householdId/meal-plans/:id/status', + schema: { + params: householdParams.extend({ id: z.string() }), + body: z.object({ + status: z.nativeEnum(MealPlanStatus), + }), + response: { 200: MealPlanResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('mealPlanService'); + const plan = await service.updateStatus( + request.params.id, + request.params.householdId, + request.body.status + ); + return reply.send(toPlanResponse(plan as AnyPlanDoc)); + }, + }); + + // DELETE /api/v1/households/:householdId/meal-plans/:id — remove plan + app.route({ + method: 'DELETE', + url: '/api/v1/households/:householdId/meal-plans/:id', + schema: { + params: householdParams.extend({ id: z.string() }), + response: { 204: z.undefined() }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('mealPlanService'); + await service.delete(request.params.id, request.params.householdId); + return reply.status(204).send(); + }, + }); + + // GET /api/v1/households/:householdId/meal-plans/:id/gap — report shopping deficiencies + app.route({ + method: 'GET', + url: '/api/v1/households/:householdId/meal-plans/:id/gap', + schema: { + params: householdParams.extend({ id: z.string() }), + response: { + 200: z.object({ + mealPlanId: z.string(), + missingItems: z.array( + z.object({ + productId: z.string(), + productName: z.string(), + category: z.string(), + requiredQuantity: z.number(), + pantryQuantity: z.number(), + missingQuantity: z.number(), + unit: z.string(), + }) + ), + }), + }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('shoppingGapService'); + const result = await service.calculateGap( + request.params.householdId, + request.params.id + ); + return reply.send(result); + }, + }); + }, + { + name: 'meal-plans-routes', + dependencies: ['auth-plugin'], + } +); 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 new file mode 100644 index 0000000..7060e0f --- /dev/null +++ b/packages/api/src/modules/meal-plans/meal-plans.service.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MealPlanService } from './meal-plans.service.js'; +import type { MealPlanRepository } from './meal-plans.repository.js'; +import { MealPlanStatus, MealType } from '@meshitrack/shared'; +import { BadRequestError, NotFoundError } from '../../common/errors.js'; + +describe(MealPlanService.name, () => { + let service: MealPlanService; + let mockRepo: { [K in keyof MealPlanRepository]: ReturnType }; + + beforeEach(() => { + mockRepo = { + findByHousehold: vi.fn(), + findById: vi.fn(), + findByWeek: vi.fn(), + create: vi.fn(), + update: vi.fn(), + updateStatus: vi.fn(), + delete: vi.fn(), + } as never; + + service = new MealPlanService({ + mealPlanRepository: mockRepo as unknown as MealPlanRepository, + }); + }); + + describe('list', () => { + it('delegates to repository', async () => { + const query = { limit: 10 }; + const mockResult = { data: [], pagination: { cursor: null, hasMore: false } }; + mockRepo.findByHousehold.mockResolvedValue(mockResult); + + const result = await service.list('hh1', query); + expect(mockRepo.findByHousehold).toHaveBeenCalledWith('hh1', query); + expect(result).toEqual(mockResult); + }); + }); + + describe('getById', () => { + it('returns plan if found', async () => { + const mockPlan = { _id: 'p1' }; + mockRepo.findById.mockResolvedValue(mockPlan); + + const result = await service.getById('p1', 'hh1'); + expect(mockRepo.findById).toHaveBeenCalledWith('p1', 'hh1'); + expect(result).toEqual(mockPlan); + }); + + it('throws NotFoundError if not found', async () => { + mockRepo.findById.mockResolvedValue(null); + await expect(service.getById('p1', 'hh1')).rejects.toThrow(NotFoundError); + }); + }); + + describe('create', () => { + const mockPerServingNutrition = { + calories: 100, + protein: 10, + carbs: 20, + fat: 5, + fiber: 2, + sugar: 3, + sodium: 100, + saturatedFat: 1, + cholesterol: 10, + }; + + const emptyDays = Array.from({ length: 7 }, (_, i) => ({ + date: `2026-05-${10 + i}`, + meals: [], + dailyNutritionTotal: { + calories: 0, + protein: 0, + carbs: 0, + fat: 0, + fiber: 0, + sugar: 0, + sodium: 0, + saturatedFat: 0, + cholesterol: 0, + }, + })); + + it('calculates day totals and delegates to repository', async () => { + mockRepo.findByWeek.mockResolvedValue(null); + mockRepo.create.mockImplementation((data) => Promise.resolve({ ...data, _id: 'new-id' })); + + const daysWithMeal = [...emptyDays]; + daysWithMeal[0] = { + date: '2026-05-10', + meals: [ + { + id: 'meal-uuid-1', + type: MealType.BREAKFAST, + recipeName: 'Eggs', + servings: 2, + perServingNutrition: mockPerServingNutrition, + }, + ], + // Let's deliberately pass incorrect values to verify the service forces recalculation! + dailyNutritionTotal: { calories: 999, protein: 99, carbs: 99, fat: 99 }, + }; + + const input = { + weekStartDate: '2026-05-10', + days: daysWithMeal, + status: MealPlanStatus.DRAFT, + }; + + const result = await service.create('hh1', 'user1', input); + + expect(mockRepo.findByWeek).toHaveBeenCalledWith('hh1', '2026-05-10'); + expect(mockRepo.create).toHaveBeenCalled(); + + // Verify recalculation happened (perServing x 2 servings) + expect(result.days[0].dailyNutritionTotal).toEqual({ + calories: 200, + protein: 20, + carbs: 40, + fat: 10, + fiber: 4, + sugar: 6, + sodium: 200, + saturatedFat: 2, + cholesterol: 20, + }); + }); + + it('uses customNutrition over perServingNutrition if present', async () => { + mockRepo.findByWeek.mockResolvedValue(null); + mockRepo.create.mockImplementation((data) => Promise.resolve({ ...data, _id: 'new-id' })); + + const daysWithCustom = [...emptyDays]; + daysWithCustom[1] = { + date: '2026-05-11', + meals: [ + { + id: 'meal-uuid-2', + type: MealType.LUNCH, + recipeName: 'Custom Item', + servings: 1, + perServingNutrition: mockPerServingNutrition, // 100 calories + customNutrition: { + calories: 300, + protein: 30, + carbs: 5, + fat: 15, + }, + }, + ], + dailyNutritionTotal: { calories: 0, protein: 0, carbs: 0, fat: 0 }, + }; + + const input = { + weekStartDate: '2026-05-10', + days: daysWithCustom, + status: MealPlanStatus.DRAFT, + }; + + const result = await service.create('hh1', 'user1', input); + expect(result.days[1].dailyNutritionTotal.calories).toBe(300); + expect(result.days[1].dailyNutritionTotal.protein).toBe(30); + }); + + it('throws BadRequestError if plan already exists for the week', async () => { + mockRepo.findByWeek.mockResolvedValue({ _id: 'existing-id' }); + const input = { + weekStartDate: '2026-05-10', + days: emptyDays, + status: MealPlanStatus.DRAFT, + }; + + await expect(service.create('hh1', 'user1', input)).rejects.toThrow(BadRequestError); + }); + }); + + describe('update', () => { + const existingPlan = { _id: 'p1', householdId: 'hh1', status: MealPlanStatus.DRAFT }; + + beforeEach(() => { + mockRepo.findById.mockResolvedValue(existingPlan); + }); + + it('updates values and recalculates days if updated', async () => { + mockRepo.update.mockImplementation((id, hh, data) => Promise.resolve({ ...existingPlan, ...data })); + + const emptyDays = Array.from({ length: 7 }, (_, i) => ({ + date: `2026-05-${10 + i}`, + meals: [], + dailyNutritionTotal: { + calories: 0, + protein: 0, + carbs: 0, + fat: 0, + fiber: 0, + sugar: 0, + sodium: 0, + saturatedFat: 0, + cholesterol: 0, + }, + })); + + const result = await service.update('p1', 'hh1', { + status: MealPlanStatus.ACTIVE, + days: emptyDays, + }); + + expect(mockRepo.update).toHaveBeenCalledWith('p1', 'hh1', { + status: MealPlanStatus.ACTIVE, + days: emptyDays, + }); + expect(result.status).toBe(MealPlanStatus.ACTIVE); + }); + }); + + describe('updateStatus', () => { + it('delegates update status to repository', async () => { + mockRepo.findById.mockResolvedValue({ _id: 'p1' }); + mockRepo.updateStatus.mockResolvedValue({ _id: 'p1', status: MealPlanStatus.ARCHIVED }); + + const result = await service.updateStatus('p1', 'hh1', MealPlanStatus.ARCHIVED); + expect(mockRepo.updateStatus).toHaveBeenCalledWith('p1', 'hh1', MealPlanStatus.ARCHIVED); + expect(result.status).toBe(MealPlanStatus.ARCHIVED); + }); + }); + + describe('delete', () => { + it('delegates deletion if found', async () => { + mockRepo.findById.mockResolvedValue({ _id: 'p1' }); + mockRepo.delete.mockResolvedValue({ _id: 'p1' }); + + const result = await service.delete('p1', 'hh1'); + expect(mockRepo.delete).toHaveBeenCalledWith('p1', 'hh1'); + expect(result).toEqual({ _id: 'p1' }); + }); + }); +}); diff --git a/packages/api/src/modules/meal-plans/meal-plans.service.ts b/packages/api/src/modules/meal-plans/meal-plans.service.ts new file mode 100644 index 0000000..2066b77 --- /dev/null +++ b/packages/api/src/modules/meal-plans/meal-plans.service.ts @@ -0,0 +1,166 @@ +import type { MealPlanRepository } from './meal-plans.repository.js'; +import { NotFoundError, BadRequestError } from '../../common/errors.js'; +import type { + CreateMealPlanInput, + UpdateMealPlanInput, + MealPlanQueryInput, + MealPlanStatus, + NutritionInfo, + MealPlanDaySchema, +} from '@meshitrack/shared'; +import { z } from 'zod/v4'; + +type MealPlanDay = z.infer; + +interface Deps { + mealPlanRepository: MealPlanRepository; +} + +export class MealPlanService { + private readonly mealPlanRepository: MealPlanRepository; + + public constructor({ mealPlanRepository }: Deps) { + this.mealPlanRepository = mealPlanRepository; + } + + public async list(householdId: string, query: MealPlanQueryInput) { + return this.mealPlanRepository.findByHousehold(householdId, query); + } + + public async getById(id: string, householdId: string) { + const plan = await this.mealPlanRepository.findById(id, householdId); + if (!plan) { + throw new NotFoundError('Meal plan not found'); + } + return plan; + } + + public async getByWeek(householdId: string, weekStartDate: string) { + return this.mealPlanRepository.findByWeek(householdId, weekStartDate); + } + + public async create( + householdId: string, + createdBy: string, + input: CreateMealPlanInput + ) { + // Prevent overlapping meal plans for same household/week + const existing = await this.mealPlanRepository.findByWeek( + householdId, + input.weekStartDate + ); + if (existing) { + throw new BadRequestError( + `A meal plan already exists for household ${householdId} starting ${input.weekStartDate}` + ); + } + + // Force re-calculation of daily totals to ensure correctness + const updatedDays = input.days.map((day) => this.computeDayTotals(day)); + + return this.mealPlanRepository.create({ + ...input, + days: updatedDays, + householdId, + createdBy, + }); + } + + public async update( + id: string, + householdId: string, + input: UpdateMealPlanInput + ) { + const existing = await this.getById(id, householdId); + + const data: Record = {}; + + if (input.status !== undefined) data.status = input.status; + if (input.shoppingListId !== undefined) data.shoppingListId = input.shoppingListId; + + if (input.days !== undefined) { + // Force re-calculation of daily totals + data.days = input.days.map((day) => this.computeDayTotals(day)); + } + + const updated = await this.mealPlanRepository.update(id, householdId, data); + if (!updated) { + throw new NotFoundError('Meal plan not found'); + } + return updated; + } + + public async updateStatus( + id: string, + householdId: string, + status: MealPlanStatus + ) { + await this.getById(id, householdId); + const updated = await this.mealPlanRepository.updateStatus(id, householdId, status); + if (!updated) { + throw new NotFoundError('Meal plan not found'); + } + return updated; + } + + public async delete(id: string, householdId: string) { + await this.getById(id, householdId); + const deleted = await this.mealPlanRepository.delete(id, householdId); + if (!deleted) { + throw new NotFoundError('Meal plan not found'); + } + return deleted; + } + + /** Calculates standard daily totals based on the component meals. */ + private computeDayTotals(day: MealPlanDay): MealPlanDay { + const total: NutritionInfo = { + calories: 0, + protein: 0, + carbs: 0, + fat: 0, + fiber: 0, + sugar: 0, + sodium: 0, + saturatedFat: 0, + cholesterol: 0, + }; + + for (const meal of day.meals) { + const servings = meal.servings; + // Use customNutrition if provided, otherwise scale perServingNutrition + const source = meal.customNutrition ?? meal.perServingNutrition; + + total.calories += source.calories * servings; + total.protein += source.protein * servings; + 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.saturatedFat != null) { + total.saturatedFat = (total.saturatedFat ?? 0) + source.saturatedFat * servings; + } + if (source.cholesterol != null) { + total.cholesterol = (total.cholesterol ?? 0) + source.cholesterol * servings; + } + } + + // Round final totals to 2 decimal places + return { + ...day, + dailyNutritionTotal: { + calories: Math.round(total.calories * 100) / 100, + 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, + }, + }; + } +} 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 new file mode 100644 index 0000000..837b6f1 --- /dev/null +++ b/packages/api/src/modules/meal-plans/shopping-gap.service.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ShoppingGapService } from './shopping-gap.service.js'; +import type { MealPlanRepository } from './meal-plans.repository.js'; +import type { RecipesRepository } from '../recipes/recipes.repository.js'; +import type { PantryRepository } from '../pantry/pantry.repository.js'; +import type { ProductsRepository } from '../products/products.repository.js'; +import { NotFoundError } from '../../common/errors.js'; + +describe(ShoppingGapService.name, () => { + let service: ShoppingGapService; + let mockMealRepo: { [K in keyof MealPlanRepository]: ReturnType }; + let mockRecipesRepo: { [K in keyof RecipesRepository]: ReturnType }; + let mockPantryRepo: { [K in keyof PantryRepository]: ReturnType }; + let mockProductsRepo: { [K in keyof ProductsRepository]: ReturnType }; + + beforeEach(() => { + mockMealRepo = { findById: vi.fn() } as never; + mockRecipesRepo = { findById: vi.fn() } as never; + mockPantryRepo = { findActiveByHousehold: vi.fn() } as never; + mockProductsRepo = { findByIds: vi.fn() } as never; + + service = new ShoppingGapService({ + mealPlanRepository: mockMealRepo as unknown as MealPlanRepository, + recipesRepository: mockRecipesRepo as unknown as RecipesRepository, + pantryRepository: mockPantryRepo as unknown as PantryRepository, + productsRepository: mockProductsRepo as unknown as ProductsRepository, + }); + }); + + describe('calculateGap', () => { + it('throws NotFoundError if plan is missing', async () => { + mockMealRepo.findById.mockResolvedValue(null); + await expect(service.calculateGap('hh1', 'p1')).rejects.toThrow(NotFoundError); + }); + + it('correctly scales recipe ingredients and contrasts against pantry', async () => { + // 1. Setup Meal Plan with 1 meal + // Recipe A planned for 4 servings. + mockMealRepo.findById.mockResolvedValue({ + _id: 'plan1', + days: [ + { + meals: [ + { recipeId: 'recipe1', servings: 4 } + ] + } + ] + }); + + // 2. Recipe 1: serves 2, needs 100g of ProdA (total needed = 200g for 4 servings) + mockRecipesRepo.findById.mockResolvedValue({ + _id: 'recipe1', + servings: 2, + ingredients: [ + { productId: 'prodA', quantity: 100, unit: 'g', isOptional: false } + ] + }); + + // 3. Products Info + mockProductsRepo.findByIds.mockResolvedValue([ + { _id: 'prodA', name: 'Flour', category: 'baking' } + ]); + + // 4. Pantry only has 50g of ProdA. Missing amount should be 150g! + mockPantryRepo.findActiveByHousehold.mockResolvedValue([ + { productId: 'prodA', quantity: 50 } + ]); + + const result = await service.calculateGap('hh1', 'plan1'); + + expect(result.mealPlanId).toBe('plan1'); + expect(result.missingItems.length).toBe(1); + + const gap = result.missingItems[0]!; + expect(gap.productId).toBe('prodA'); + expect(gap.productName).toBe('Flour'); + expect(gap.requiredQuantity).toBe(200); // 100g * (4 planned / 2 base) + expect(gap.pantryQuantity).toBe(50); + expect(gap.missingQuantity).toBe(150); + expect(gap.unit).toBe('g'); + }); + + it('does not include products that are fully stocked', async () => { + mockMealRepo.findById.mockResolvedValue({ + _id: 'plan2', + days: [ + { + meals: [{ recipeId: 'recipe1', servings: 2 }] + } + ] + }); + + mockRecipesRepo.findById.mockResolvedValue({ + _id: 'recipe1', + servings: 2, + ingredients: [ + { productId: 'prodB', quantity: 50, unit: 'g', isOptional: false } + ] + }); + + mockProductsRepo.findByIds.mockResolvedValue([{ _id: 'prodB', name: 'Salt' }]); + + // Pantry has 100g (more than enough) + mockPantryRepo.findActiveByHousehold.mockResolvedValue([{ productId: 'prodB', quantity: 100 }]); + + const result = await service.calculateGap('hh1', 'plan2'); + expect(result.missingItems.length).toBe(0); + }); + }); +}); diff --git a/packages/api/src/modules/meal-plans/shopping-gap.service.ts b/packages/api/src/modules/meal-plans/shopping-gap.service.ts new file mode 100644 index 0000000..e9e8b98 --- /dev/null +++ b/packages/api/src/modules/meal-plans/shopping-gap.service.ts @@ -0,0 +1,162 @@ +import type { MealPlanRepository } from './meal-plans.repository.js'; +import type { RecipesRepository } from '../recipes/recipes.repository.js'; +import type { PantryRepository } from '../pantry/pantry.repository.js'; +import type { ProductsRepository } from '../products/products.repository.js'; +import { NotFoundError } from '../../common/errors.js'; + +interface Deps { + mealPlanRepository: MealPlanRepository; + recipesRepository: RecipesRepository; + pantryRepository: PantryRepository; + productsRepository: ProductsRepository; +} + +export interface GapItem { + productId: string; + productName: string; + category: string; + requiredQuantity: number; + pantryQuantity: number; + missingQuantity: number; + unit: string; +} + +export interface ShoppingGapResult { + mealPlanId: string; + missingItems: GapItem[]; +} + +export class ShoppingGapService { + private readonly mealPlanRepository: MealPlanRepository; + private readonly recipesRepository: RecipesRepository; + private readonly pantryRepository: PantryRepository; + private readonly productsRepository: ProductsRepository; + + public constructor({ + mealPlanRepository, + recipesRepository, + pantryRepository, + productsRepository, + }: Deps) { + this.mealPlanRepository = mealPlanRepository; + this.recipesRepository = recipesRepository; + this.pantryRepository = pantryRepository; + this.productsRepository = productsRepository; + } + + public async calculateGap( + householdId: string, + mealPlanId: string + ): Promise { + const plan = await this.mealPlanRepository.findById(mealPlanId, householdId); + if (!plan) { + throw new NotFoundError('Meal plan not found'); + } + + // 1. Collect unique recipe IDs in the plan + const recipeIdSet = new Set(); + const plannedMealsList: Array<{ recipeId: string; plannedServings: number }> = []; + + const recPlan = plan as Record; + const days = (recPlan.days as any[]) || []; + for (const day of days) { + const meals = (day.meals as any[]) || []; + for (const meal of meals) { + if (meal.recipeId) { + recipeIdSet.add(meal.recipeId); + plannedMealsList.push({ + recipeId: meal.recipeId, + plannedServings: meal.servings, + }); + } + } + } + + // 2. Fetch all referenced recipes in parallel + const recipeIds = Array.from(recipeIdSet); + const recipeDocs = await Promise.all( + recipeIds.map((id) => this.recipesRepository.findById(id, householdId)) + ); + const recipesMap = new Map(); + for (const doc of recipeDocs) { + if (doc) { + recipesMap.set((doc as any)._id.toString(), doc); + } + } + + // 3. Aggregate required quantities of each Product + // Map of productId -> { qty, unit } + const requiredMap = new Map(); + + for (const planned of plannedMealsList) { + const recipe = recipesMap.get(planned.recipeId); + if (!recipe) continue; + + const baseServings = recipe.servings || 1; + const scalingFactor = planned.plannedServings / baseServings; + + const ingredients = (recipe.ingredients as any[]) || []; + for (const ing of ingredients) { + if (ing.isOptional) continue; + + const productId = ing.productId as string; + const scaledQty = (ing.quantity as number) * scalingFactor; + const unit = (ing.unit as string) || 'g'; + + const existing = requiredMap.get(productId); + if (existing) { + existing.qty += scaledQty; + } else { + requiredMap.set(productId, { qty: scaledQty, unit }); + } + } + } + + // 4. Fetch active pantry inventory & relevant Product models + const activePantry = await this.pantryRepository.findActiveByHousehold(householdId); + const requiredProductIds = Array.from(requiredMap.keys()); + const productDocs = await this.productsRepository.findByIds(householdId, requiredProductIds); + + const productsInfoMap = new Map(); + for (const p of productDocs) { + productsInfoMap.set(p._id.toString(), p); + } + + // Aggregate current Pantry items by productId + const pantryMap = new Map(); + for (const item of activePantry) { + const rec = item as Record; + const prodId = rec.productId as string; + const qty = (rec.quantity as number) || 0; + pantryMap.set(prodId, (pantryMap.get(prodId) || 0) + qty); + } + + // 5. Contrast requirements vs inventory to find missing gaps + const missingItems: GapItem[] = []; + + for (const [productId, req] of requiredMap.entries()) { + const pantryQty = pantryMap.get(productId) || 0; + + if (pantryQty < req.qty) { + const productDoc = productsInfoMap.get(productId); + const productName = productDoc?.name || 'Unknown Ingredient'; + const category = productDoc?.category || 'other'; + + missingItems.push({ + productId, + productName, + category, + requiredQuantity: Math.round(req.qty * 100) / 100, + pantryQuantity: Math.round(pantryQty * 100) / 100, + missingQuantity: Math.round((req.qty - pantryQty) * 100) / 100, + unit: req.unit, + }); + } + } + + return { + mealPlanId, + missingItems: missingItems.sort((a, b) => a.productName.localeCompare(b.productName)), + }; + } +} 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 new file mode 100644 index 0000000..f3a8895 --- /dev/null +++ b/packages/api/src/modules/meal-plans/suggestion-engine.service.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { SuggestionEngineService } from './suggestion-engine.service.js'; +import type { RecipesRepository } from '../recipes/recipes.repository.js'; +import type { PantryRepository } from '../pantry/pantry.repository.js'; +import type { MealPlanRepository } from './meal-plans.repository.js'; +import type { NutritionTargetRepository } from '../nutrition-targets/nutrition-target.repository.js'; + +describe(SuggestionEngineService.name, () => { + let service: SuggestionEngineService; + let mockRecipesRepo: { [K in keyof RecipesRepository]: ReturnType }; + let mockPantryRepo: { [K in keyof PantryRepository]: ReturnType }; + let mockMealPlanRepo: { [K in keyof MealPlanRepository]: ReturnType }; + let mockNutritionRepo: { [K in keyof NutritionTargetRepository]: ReturnType }; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-20T00:00:00Z')); + + mockRecipesRepo = { + findByHousehold: vi.fn(), + } as never; + + mockPantryRepo = { + findActiveByHousehold: vi.fn(), + } as never; + + mockMealPlanRepo = { + findByHousehold: vi.fn(), + } as never; + + mockNutritionRepo = { + findByUser: vi.fn(), + } as never; + + service = new SuggestionEngineService({ + recipesRepository: mockRecipesRepo as unknown as RecipesRepository, + pantryRepository: mockPantryRepo as unknown as PantryRepository, + mealPlanRepository: mockMealPlanRepo as unknown as MealPlanRepository, + nutritionTargetRepository: mockNutritionRepo as unknown as NutritionTargetRepository, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('getSuggestions', () => { + it('correctly ranks recipes based on inventory coverage and freshness', async () => { + // 1. Set up recipes: + // - Recipe A: Needs Product 1 (2 units) and Product 2 (1 unit) + // - Recipe B: Needs Product 3 (1 unit) + const recipeA = { + _id: 'recipeA', + name: 'Recipe A', + ingredients: [ + { productId: 'prod1', quantity: 2, isOptional: false }, + { productId: 'prod2', quantity: 1, isOptional: false }, + ], + perServingNutrition: { calories: 400, protein: 30, carbs: 40, fat: 10 }, // balanced + }; + + const recipeB = { + _id: 'recipeB', + name: 'Recipe B', + ingredients: [ + { productId: 'prod3', quantity: 1, isOptional: false }, + ], + perServingNutrition: { calories: 600, protein: 10, carbs: 100, fat: 15 }, // high carb + }; + + mockRecipesRepo.findByHousehold.mockResolvedValue({ + data: [recipeA, recipeB], + pagination: { hasMore: false }, + }); + + // 2. Set up Pantry inventory: + // We have Product 1 in abundance (expiringSoon). + // We have Product 2 (fresh). + // Product 3 is NOT in pantry. + mockPantryRepo.findActiveByHousehold.mockResolvedValue([ + { + productId: 'prod1', + quantity: 10, + freshnessEstimate: { daysRemaining: 2, urgency: 'expiringSoon' }, + }, + { + productId: 'prod2', + quantity: 5, + freshnessEstimate: { daysRemaining: 15, urgency: 'fresh' }, + }, + ]); + + // 3. Set up standard nutrition target (Maintenance: 30p/40c/30f split) + // Macro split match logic: + // Recipe A: 400cals, 30g Protein(120cals=30%), 40g Carbs(160cals=40%), 10g Fat(90cals=22.5%) -> highly aligned! + mockNutritionRepo.findByUser.mockResolvedValue({ + dailyCalories: 2000, + proteinG: 150, // (150 * 4) = 600cals (30%) + carbsG: 200, // (200 * 4) = 800cals (40%) + fatG: 67, // (67 * 9) = 603cals (30%) + }); + + // 4. Set up recent meal plans (empty history -> 100% Variety for all) + mockMealPlanRepo.findByHousehold.mockResolvedValue({ + data: [], + }); + + // Run suggestion fetch + const suggestions = await service.getSuggestions('hh1', 'user1'); + + // Assertions: + expect(suggestions.length).toBe(2); + + // Recipe A should clearly rank #1 (100% Coverage, using urgent items, highly nutritious match) + const top = suggestions[0]!; + expect(top.recipeId).toBe('recipeA'); + expect(top.scores.coverage).toBe(1); // full coverage + // Urgency: (expiringSoon[0.7] + fresh[0.1]) / 2 = 0.4 + expect(top.scores.urgency).toBeGreaterThan(0.3); + expect(top.scores.variety).toBe(1); // never eaten + + // Recipe B should have 0 coverage and thus lower totalScore + const bottom = suggestions[1]!; + expect(bottom.recipeId).toBe('recipeB'); + expect(bottom.scores.coverage).toBe(0); + expect(bottom.totalScore).toBeLessThan(top.totalScore); + }); + + it('penalizes recipes eaten recently (Variety score)', async () => { + const recipeX = { + _id: 'recipeX', + name: 'Recipe X', + ingredients: [], + perServingNutrition: { calories: 100, protein: 5, carbs: 10, fat: 2 }, + }; + + mockRecipesRepo.findByHousehold.mockResolvedValue({ data: [recipeX] }); + mockPantryRepo.findActiveByHousehold.mockResolvedValue([]); + mockNutritionRepo.findByUser.mockResolvedValue(null); + + // Fake history: Recipe X was eaten 7 days ago + const date7DaysAgo = new Date(); + date7DaysAgo.setDate(date7DaysAgo.getDate() - 7); + const dateStr = date7DaysAgo.toISOString().split('T')[0]; + + mockMealPlanRepo.findByHousehold.mockResolvedValue({ + data: [ + { + days: [ + { + date: dateStr, + meals: [{ recipeId: 'recipeX' }], + }, + ], + }, + ], + }); + + const suggestions = await service.getSuggestions('hh1', 'user1'); + + // Variety calculation: 7 days ago / 14 days = 0.5 + expect(suggestions[0]!.scores.variety).toBeCloseTo(0.5, 1); + }); + }); +}); diff --git a/packages/api/src/modules/meal-plans/suggestion-engine.service.ts b/packages/api/src/modules/meal-plans/suggestion-engine.service.ts new file mode 100644 index 0000000..2c38772 --- /dev/null +++ b/packages/api/src/modules/meal-plans/suggestion-engine.service.ts @@ -0,0 +1,291 @@ +import type { RecipesRepository } from '../recipes/recipes.repository.js'; +import type { PantryRepository } from '../pantry/pantry.repository.js'; +import type { MealPlanRepository } from './meal-plans.repository.js'; +import type { NutritionTargetRepository } from '../nutrition-targets/nutrition-target.repository.js'; + +interface Deps { + recipesRepository: RecipesRepository; + pantryRepository: PantryRepository; + mealPlanRepository: MealPlanRepository; + nutritionTargetRepository: NutritionTargetRepository; +} + +export interface ScoredRecipe { + recipeId: string; + recipeName: string; + totalScore: number; + scores: { + coverage: number; + urgency: number; + nutrition: number; + variety: number; + }; + reasoning: string[]; +} + +export class SuggestionEngineService { + private readonly recipesRepository: RecipesRepository; + private readonly pantryRepository: PantryRepository; + private readonly mealPlanRepository: MealPlanRepository; + private readonly nutritionTargetRepository: NutritionTargetRepository; + + public constructor({ + recipesRepository, + pantryRepository, + mealPlanRepository, + nutritionTargetRepository, + }: Deps) { + this.recipesRepository = recipesRepository; + this.pantryRepository = pantryRepository; + this.mealPlanRepository = mealPlanRepository; + this.nutritionTargetRepository = nutritionTargetRepository; + } + + public async getSuggestions( + householdId: string, + userId: string, + options: { limit?: number } = {} + ): Promise { + const limit = options.limit ?? 5; + + // 1. Parallel fetch state + const [recipesResult, pantryItems, activeTarget, recentPlansResult] = await Promise.all([ + this.recipesRepository.findByHousehold(householdId, { limit: 1000 }), + this.pantryRepository.findActiveByHousehold(householdId), + this.nutritionTargetRepository.findByUser(userId, householdId), + this.mealPlanRepository.findByHousehold(householdId, { limit: 5 }), // scan last ~5 weeks to cover 14 days + ]); + + const recipes = recipesResult.data; + + // 2. Process Pantry inventory for fast O(1) access + // Map of productId -> { totalQty, minDaysRemaining, maxUrgencyWeight } + const pantryInventory = new Map< + string, + { qty: number; minDays: number; maxUrgencyWeight: number } + >(); + + for (const item of pantryItems) { + const rec = item as Record; + const productId = rec.productId as string; + const quantity = (rec.quantity as number) ?? 0; + const fresh = (rec.freshnessEstimate ?? {}) as Record; + const daysRemaining = (fresh.daysRemaining as number) ?? 999; + const urgencyStr = (fresh.urgency as string) || 'normal'; + + const urgencyWeight = this.getUrgencyWeight(urgencyStr); + + const existing = pantryInventory.get(productId); + if (existing) { + existing.qty += quantity; + existing.minDays = Math.min(existing.minDays, daysRemaining); + existing.maxUrgencyWeight = Math.max(existing.maxUrgencyWeight, urgencyWeight); + } else { + pantryInventory.set(productId, { + qty: quantity, + minDays: daysRemaining, + maxUrgencyWeight: urgencyWeight, + }); + } + } + + // 3. Build History Map for Variety scoring (last 14 days) + const lastEaten = new Map(); // recipeId -> daysAgo + const now = new Date(); + + for (const plan of recentPlansResult.data) { + const recPlan = plan as Record; + const days = (recPlan.days as any[]) || []; + for (const day of days) { + const dateStr = day.date as string; + const dayDate = new Date(dateStr); + const diffTime = Math.abs(now.getTime() - dayDate.getTime()); + const daysAgo = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (daysAgo <= 14) { + const meals = (day.meals as any[]) || []; + for (const meal of meals) { + const recipeId = meal.recipeId as string; + if (!recipeId) continue; + + const currentMin = lastEaten.get(recipeId); + if (currentMin === undefined || daysAgo < currentMin) { + lastEaten.set(recipeId, daysAgo); + } + } + } + } + } + + // 4. Score each recipe + const scoredList: ScoredRecipe[] = []; + + for (const recipe of recipes) { + const rec = recipe as Record; + const recipeId = (rec._id as { toString(): string }).toString(); + const ingredients = (rec.ingredients as any[]) || []; + const recipeName = (rec.name as string) || 'Unknown Recipe'; + + const scoreBreakdown = this.scoreRecipe( + ingredients, + rec.perServingNutrition as Record | undefined, + recipeId, + pantryInventory, + activeTarget as Record | null, + lastEaten + ); + + const totalScore = + scoreBreakdown.coverage * 0.4 + + scoreBreakdown.urgency * 0.3 + + scoreBreakdown.nutrition * 0.2 + + scoreBreakdown.variety * 0.1; + + const roundedScore = Math.round(totalScore * 1000) / 1000; + + scoredList.push({ + recipeId, + recipeName, + totalScore: roundedScore, + scores: scoreBreakdown, + reasoning: this.generateReasoning(scoreBreakdown, recipeName), + }); + } + + // 5. Sort descending and limit + return scoredList + .sort((a, b) => b.totalScore - a.totalScore) + .slice(0, limit); + } + + private scoreRecipe( + ingredients: any[], + nutrition: Record | undefined, + recipeId: string, + inventory: Map, + target: Record | null, + lastEaten: Map + ) { + // Filter non-optional ingredients + const requiredIngs = ingredients.filter((ing) => !ing.isOptional); + + // -- COVERAGE & URGENCY -- + let coverageScore = 1.0; + let urgencyScore = 0.0; + + if (requiredIngs.length > 0) { + let totalMatches = 0; + let weightedUrgencySum = 0; + + for (const ing of requiredIngs) { + const inv = inventory.get(ing.productId); + if (inv && inv.qty > 0) { + // If we have full quantity, 1.0, else partial + const coverageFraction = Math.min(1.0, inv.qty / ing.quantity); + totalMatches += coverageFraction; + weightedUrgencySum += inv.maxUrgencyWeight * coverageFraction; + } + } + + coverageScore = totalMatches / requiredIngs.length; + // Normalize urgency as average urgency over ingredients used + urgencyScore = totalMatches > 0 ? weightedUrgencySum / totalMatches : 0.0; + } + + // -- NUTRITION -- + let nutritionScore = 1.0; // Neutral by default + if (target && nutrition && nutrition.calories > 0) { + const tCals = (target.dailyCalories as number) || 2000; + const tP = (target.proteinG as number) || 0; + const tC = (target.carbsG as number) || 0; + const tF = (target.fatG as number) || 0; + + const rCals = nutrition.calories; + const rP = nutrition.protein || 0; + const rC = nutrition.carbs || 0; + const rF = nutrition.fat || 0; + + if (tP || tC || tF) { + // Target macro percentages + const targetProtPct = (tP * 4) / tCals; + const targetCarbPct = (tC * 4) / tCals; + const targetFatPct = (tF * 9) / tCals; + + // Recipe macro percentages + const recProtPct = (rP * 4) / rCals; + const recCarbPct = (rC * 4) / rCals; + const recFatPct = (rF * 9) / rCals; + + // Sum of absolute diffs + const diff = + Math.abs(targetProtPct - recProtPct) + + Math.abs(targetCarbPct - recCarbPct) + + Math.abs(targetFatPct - recFatPct); + + // Lower diff means higher score. Max diff is 2.0 theoretically. + nutritionScore = Math.max(0, 1.0 - diff / 2.0); + } + } + + // -- VARIETY -- + let varietyScore = 1.0; + const daysAgo = lastEaten.get(recipeId); + if (daysAgo !== undefined) { + // Linear scaling over 14 days + varietyScore = daysAgo / 14; + } + + return { + coverage: Math.round(coverageScore * 100) / 100, + urgency: Math.round(urgencyScore * 100) / 100, + nutrition: Math.round(nutritionScore * 100) / 100, + variety: Math.round(varietyScore * 100) / 100, + }; + } + + private getUrgencyWeight(urgency: string): number { + switch (urgency) { + case 'expired': + case 'urgent': + return 1.0; + case 'soon': + case 'expiringSoon': + return 0.7; + case 'normal': + return 0.3; + case 'fresh': + return 0.1; + default: + return 0.0; + } + } + + private generateReasoning( + scores: { coverage: number; urgency: number; nutrition: number; variety: number }, + recipeName: string + ): string[] { + const reasons: string[] = []; + + if (scores.coverage > 0.9) { + reasons.push(`You have almost all the ingredients for ${recipeName} right now.`); + } else if (scores.coverage > 0.5) { + reasons.push(`Uses several ingredients already stocked in your pantry.`); + } + + if (scores.urgency > 0.7) { + reasons.push(`High priority: Saves expiring pantry items from going to waste!`); + } else if (scores.urgency > 0.4) { + reasons.push(`Helps use up items that should be consumed soon.`); + } + + if (scores.nutrition > 0.85) { + reasons.push(`Matches your daily nutritional macro goals very closely.`); + } + + if (scores.variety > 0.95 && (scores.coverage > 0.5 || scores.urgency > 0.5)) { + reasons.push(`You haven't had this in over two weeks, adding good variety.`); + } + + return reasons; + } +} 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 new file mode 100644 index 0000000..13608fe --- /dev/null +++ b/packages/api/src/modules/nutrition-targets/nutrition-target.repository.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NutritionTargetRepository } from './nutrition-target.repository.js'; + +const { mockSave, MockTargetModel } = vi.hoisted(() => { + const mockSave = vi.fn(); + function MockModel(this: { save: typeof mockSave }, data: unknown) { + Object.assign(this, data); + this.save = mockSave; + } + Object.assign(MockModel, { + findOne: vi.fn(), + find: vi.fn(), + findOneAndUpdate: vi.fn(), + updateMany: vi.fn(), + }); + return { mockSave, MockTargetModel: MockModel }; +}); + +vi.mock('../../schemas/nutrition-target.schema.js', () => ({ + NutritionTargetModel: MockTargetModel, +})); + +const { NutritionTargetModel } = await import('../../schemas/nutrition-target.schema.js'); + +function makeChain(result: unknown = null) { + return { + sort: vi.fn().mockReturnThis(), + lean: vi.fn().mockReturnThis(), + exec: vi.fn().mockResolvedValue(result), + }; +} + +describe(NutritionTargetRepository.name, () => { + let repo: NutritionTargetRepository; + + beforeEach(() => { + vi.clearAllMocks(); + repo = new NutritionTargetRepository(); + }); + + describe('findByUser', () => { + it('queries by userId, householdId, and isActive: true', async () => { + const mockTarget = { _id: 't1', dailyCalories: 2000 }; + vi.mocked(NutritionTargetModel.findOne).mockReturnValue(makeChain(mockTarget) as never); + + const result = await repo.findByUser('user1', 'hh1'); + expect(NutritionTargetModel.findOne).toHaveBeenCalledWith({ + userId: 'user1', + householdId: 'hh1', + isActive: true, + }); + expect(result).toEqual(mockTarget); + }); + }); + + describe('findAllByUser', () => { + it('returns all targets sorted by newest first', async () => { + const chain = makeChain([]); + vi.mocked(NutritionTargetModel.find).mockReturnValue(chain as never); + + await repo.findAllByUser('user1', 'hh1'); + expect(NutritionTargetModel.find).toHaveBeenCalledWith({ + userId: 'user1', + householdId: 'hh1', + }); + expect(chain.sort).toHaveBeenCalledWith({ createdAt: -1 }); + }); + }); + + describe('create', () => { + it('saves and returns new document', async () => { + const plainDoc = { _id: 'new-id', dailyCalories: 2000 }; + mockSave.mockResolvedValue({ toObject: () => plainDoc }); + + const result = await repo.create({ dailyCalories: 2000 }); + expect(mockSave).toHaveBeenCalled(); + expect(result).toEqual(plainDoc); + }); + }); + + describe('deactivateAllForUser', () => { + it('updates all active targets for the user to inactive', async () => { + vi.mocked(NutritionTargetModel.updateMany).mockReturnValue({ + exec: vi.fn().mockResolvedValue({ modifiedCount: 1 }), + } as never); + + await repo.deactivateAllForUser('user1', 'hh1'); + expect(NutritionTargetModel.updateMany).toHaveBeenCalledWith( + { userId: 'user1', householdId: 'hh1', isActive: true }, + { $set: { isActive: false } }, + ); + }); + }); +}); diff --git a/packages/api/src/modules/nutrition-targets/nutrition-target.repository.ts b/packages/api/src/modules/nutrition-targets/nutrition-target.repository.ts new file mode 100644 index 0000000..66593db --- /dev/null +++ b/packages/api/src/modules/nutrition-targets/nutrition-target.repository.ts @@ -0,0 +1,37 @@ +import { NutritionTargetModel } from '../../schemas/nutrition-target.schema.js'; + +export class NutritionTargetRepository { + public async findByUser(userId: string, householdId: string) { + return NutritionTargetModel.findOne({ userId, householdId, isActive: true }) + .lean() + .exec(); + } + + public async findAllByUser(userId: string, householdId: string) { + return NutritionTargetModel.find({ userId, householdId }) + .sort({ createdAt: -1 }) + .lean() + .exec(); + } + + public async create(data: Record) { + const doc = new NutritionTargetModel(data); + const saved = await doc.save(); + return saved.toObject(); + } + + public async deactivateAllForUser(userId: string, householdId: string) { + return NutritionTargetModel.updateMany( + { userId, householdId, isActive: true }, + { $set: { isActive: false } } + ).exec(); + } + + public async update(id: string, userId: string, householdId: string, data: Record) { + return NutritionTargetModel.findOneAndUpdate( + { _id: id, userId, householdId }, + { $set: data }, + { new: true, lean: true } + ).exec(); + } +} 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 new file mode 100644 index 0000000..071ed8e --- /dev/null +++ b/packages/api/src/modules/nutrition-targets/nutrition-target.routes.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import Fastify from 'fastify'; +import { fastifyAwilixPlugin } from '@fastify/awilix'; +import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'; + +vi.mock('jose', () => ({ + createRemoteJWKSet: vi.fn(() => 'mock-jwks'), + jwtVerify: vi.fn().mockResolvedValue({ + payload: { + sub: 'kc-1', + email: 'test@example.com', + preferred_username: 'testuser', + realm_access: { roles: ['member'] }, + householdIds: ['hh1'], + }, + protectedHeader: { alg: 'RS256' }, + key: {}, + }), +})); + +const { + mockFindByUser, + mockFindAllByUser, + mockDeactivateAllForUser, + mockCreate, +} = vi.hoisted(() => ({ + mockFindByUser: vi.fn(), + mockFindAllByUser: vi.fn(), + mockDeactivateAllForUser: vi.fn(), + mockCreate: vi.fn(), +})); + +vi.mock('./nutrition-target.repository.js', () => ({ + NutritionTargetRepository: class { + findByUser = mockFindByUser; + findAllByUser = mockFindAllByUser; + deactivateAllForUser = mockDeactivateAllForUser; + create = mockCreate; + }, +})); + +vi.mock('../users/users.repository.js', () => ({ + UsersRepository: class { + findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] }); + upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] }); + }, +})); + +import authPlugin from '../../plugins/auth.plugin.js'; +import householdPlugin from '../../plugins/household.plugin.js'; +import usersRoutes from '../users/users.routes.js'; +import nutritionTargetRoutes from './nutrition-target.routes.js'; + +function makeTarget(overrides = {}) { + return { + _id: 'target-1', + userId: 'kc-1', + householdId: 'hh1', + dailyCalories: 2000, + proteinG: 150, + carbsG: 200, + fatG: 67, + isActive: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + }; +} + +describe('nutrition-target.routes', () => { + let app: any; + + async function buildTestApp() { + const instance = Fastify({ logger: false }); + instance.setValidatorCompiler(validatorCompiler); + instance.setSerializerCompiler(serializerCompiler); + await instance.register(fastifyAwilixPlugin, { + disposeOnClose: true, + disposeOnResponse: true, + strictBooleanEnforced: true, + }); + await instance.register(authPlugin); + await instance.register(householdPlugin); + await instance.register(usersRoutes); + await instance.register(nutritionTargetRoutes); + await instance.ready(); + return instance; + } + + const authHeaders = { authorization: 'Bearer valid-token' }; + + beforeEach(async () => { + vi.clearAllMocks(); + app = await buildTestApp(); + }); + + afterEach(async () => { + if (app) await app.close(); + }); + + describe('GET /api/v1/households/:householdId/nutrition-targets', () => { + it('returns the active target if found', async () => { + mockFindByUser.mockResolvedValue(makeTarget()); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/nutrition-targets', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.dailyCalories).toBe(2000); + expect(body.isActive).toBe(true); + }); + + it('returns a message object if no target is set', async () => { + mockFindByUser.mockResolvedValue(null); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/nutrition-targets', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().message).toBe('No active targets defined'); + }); + }); + + describe('GET /api/v1/households/:householdId/nutrition-targets/history', () => { + it('returns historical targets', async () => { + mockFindAllByUser.mockResolvedValue([makeTarget({ isActive: false }), makeTarget()]); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/nutrition-targets/history', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body).toHaveLength(2); + }); + }); + + describe('POST /api/v1/households/:householdId/nutrition-targets', () => { + it('creates target and returns it', async () => { + mockCreate.mockImplementation((data) => Promise.resolve({ ...data, _id: 'new-id', createdAt: new Date(), updatedAt: new Date() })); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/households/hh1/nutrition-targets', + headers: { ...authHeaders, 'content-type': 'application/json' }, + body: JSON.stringify({ + dailyCalories: 1800, + proteinG: 135, + carbsG: 180, + fatG: 60, + }), + }); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.dailyCalories).toBe(1800); + expect(body.isActive).toBe(true); // auto activated + }); + }); + + describe('POST /api/v1/households/:householdId/nutrition-targets/presets', () => { + it('returns calculated splits', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/households/hh1/nutrition-targets/presets', + headers: { ...authHeaders, 'content-type': 'application/json' }, + body: JSON.stringify({ + calories: 2000, + strategy: 'loss', + }), + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + // Loss is 40% protein (200g), 30% carbs (150g), 30% fat (67g) + expect(body.dailyCalories).toBe(2000); + expect(body.proteinG).toBe(200); + }); + }); +}); diff --git a/packages/api/src/modules/nutrition-targets/nutrition-target.routes.ts b/packages/api/src/modules/nutrition-targets/nutrition-target.routes.ts new file mode 100644 index 0000000..0447be1 --- /dev/null +++ b/packages/api/src/modules/nutrition-targets/nutrition-target.routes.ts @@ -0,0 +1,159 @@ +import fp from 'fastify-plugin'; +import { asClass, Lifetime } from 'awilix'; +import type { ZodTypeProvider } from 'fastify-type-provider-zod'; +import { z } from 'zod/v4'; +import { + NutritionTargetSchema, + NutritionTargetResponseSchema, +} from '@meshitrack/shared'; +import { NutritionTargetRepository } from './nutrition-target.repository.js'; +import { NutritionTargetService } from './nutrition-target.service.js'; + +type AnyTargetDoc = { + _id: string | { toString: () => string }; + userId: string; + householdId: string; + dailyCalories: number; + proteinG: number; + carbsG: number; + fatG: number; + fiberG?: number | null; + sugarG?: number | null; + sodiumMg?: number | null; + isActive: boolean; + createdAt: string | Date; + updatedAt: string | Date; +}; + +function toStr(v: string | { toString: () => string }): string { + return typeof v === 'string' ? v : v.toString(); +} + +function toIso(v: string | Date): string { + return typeof v === 'string' ? v : v.toISOString(); +} + +function toNutritionTargetResponse(doc: AnyTargetDoc): z.infer { + return { + _id: toStr(doc._id), + userId: doc.userId, + householdId: doc.householdId, + dailyCalories: doc.dailyCalories, + proteinG: doc.proteinG, + carbsG: doc.carbsG, + fatG: doc.fatG, + ...(doc.fiberG != null ? { fiberG: doc.fiberG } : {}), + ...(doc.sugarG != null ? { sugarG: doc.sugarG } : {}), + ...(doc.sodiumMg != null ? { sodiumMg: doc.sodiumMg } : {}), + isActive: doc.isActive, + createdAt: toIso(doc.createdAt), + updatedAt: toIso(doc.updatedAt), + }; +} + +declare module '@fastify/awilix' { + interface Cradle { + nutritionTargetRepository: NutritionTargetRepository; + nutritionTargetService: NutritionTargetService; + } +} + +export default fp( + async (fastify) => { + fastify.diContainer.register({ + nutritionTargetRepository: asClass(NutritionTargetRepository, { lifetime: Lifetime.SINGLETON }), + nutritionTargetService: asClass(NutritionTargetService, { lifetime: Lifetime.SINGLETON }), + }); + + const app = fastify.withTypeProvider(); + const paramsSchema = z.object({ householdId: z.string() }); + + // GET /api/v1/households/:householdId/nutrition-targets — get current active targets + app.route({ + method: 'GET', + url: '/api/v1/households/:householdId/nutrition-targets', + schema: { + params: paramsSchema, + response: { + 200: z.union([ + NutritionTargetResponseSchema, + z.object({ message: z.literal('No active targets defined') }), + ]), + }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('nutritionTargetService'); + const userId = request.user.keycloakId; + const target = await service.getActiveByUser(userId, request.params.householdId); + + if (!target) { + return reply.status(200).send({ message: 'No active targets defined' }); + } + + return reply.send(toNutritionTargetResponse(target as AnyTargetDoc)); + }, + }); + + // GET /api/v1/households/:householdId/nutrition-targets/history — get all targets historically + app.route({ + method: 'GET', + url: '/api/v1/households/:householdId/nutrition-targets/history', + schema: { + params: paramsSchema, + response: { + 200: z.array(NutritionTargetResponseSchema), + }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('nutritionTargetService'); + const userId = request.user.keycloakId; + const targets = await service.getAllByUser(userId, request.params.householdId); + return reply.send(targets.map((t) => toNutritionTargetResponse(t as AnyTargetDoc))); + }, + }); + + // POST /api/v1/households/:householdId/nutrition-targets — upsert active targets + app.route({ + method: 'POST', + url: '/api/v1/households/:householdId/nutrition-targets', + schema: { + params: paramsSchema, + body: NutritionTargetSchema, + response: { 201: NutritionTargetResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('nutritionTargetService'); + const userId = request.user.keycloakId; + const target = await service.setTarget( + userId, + request.params.householdId, + request.body + ); + return reply.status(201).send(toNutritionTargetResponse(target as AnyTargetDoc)); + }, + }); + + // POST /api/v1/households/:householdId/nutrition-targets/presets — generate macros via preset strategy + app.route({ + method: 'POST', + url: '/api/v1/households/:householdId/nutrition-targets/presets', + schema: { + params: paramsSchema, + body: z.object({ + calories: z.number().positive(), + strategy: z.enum(['maintenance', 'loss', 'gain']), + }), + response: { 200: NutritionTargetSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('nutritionTargetService'); + const calculated = service.calculatePreset(request.body.calories, request.body.strategy); + return reply.send(calculated); + }, + }); + }, + { + name: 'nutrition-targets-routes', + dependencies: ['auth-plugin'], + } +); diff --git a/packages/api/src/modules/nutrition-targets/nutrition-target.service.test.ts b/packages/api/src/modules/nutrition-targets/nutrition-target.service.test.ts new file mode 100644 index 0000000..c9c9552 --- /dev/null +++ b/packages/api/src/modules/nutrition-targets/nutrition-target.service.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NutritionTargetService } from './nutrition-target.service.js'; +import type { NutritionTargetRepository } from './nutrition-target.repository.js'; + +describe(NutritionTargetService.name, () => { + let service: NutritionTargetService; + let mockRepo: { [K in keyof NutritionTargetRepository]: ReturnType }; + + beforeEach(() => { + mockRepo = { + findByUser: vi.fn(), + findAllByUser: vi.fn(), + create: vi.fn(), + deactivateAllForUser: vi.fn(), + update: vi.fn(), + } as never; + + service = new NutritionTargetService({ + nutritionTargetRepository: mockRepo as unknown as NutritionTargetRepository, + }); + }); + + describe('getActiveByUser', () => { + it('delegates to repository', async () => { + const mockTarget = { dailyCalories: 2000, isActive: true }; + mockRepo.findByUser.mockResolvedValue(mockTarget); + + const result = await service.getActiveByUser('u1', 'h1'); + expect(mockRepo.findByUser).toHaveBeenCalledWith('u1', 'h1'); + expect(result).toEqual(mockTarget); + }); + }); + + describe('getAllByUser', () => { + it('delegates to repository', async () => { + const mockTargets = [{ dailyCalories: 2000 }, { dailyCalories: 1800 }]; + mockRepo.findAllByUser.mockResolvedValue(mockTargets); + + const result = await service.getAllByUser('u1', 'h1'); + expect(mockRepo.findAllByUser).toHaveBeenCalledWith('u1', 'h1'); + expect(result).toEqual(mockTargets); + }); + }); + + describe('setTarget', () => { + it('deactivates existing targets before creating an active one', async () => { + const mockInput = { dailyCalories: 2000, proteinG: 100, carbsG: 200, fatG: 50, isActive: true }; + const createdTarget = { ...mockInput, _id: 'new-id', userId: 'u1', householdId: 'h1' }; + mockRepo.create.mockResolvedValue(createdTarget); + + const result = await service.setTarget('u1', 'h1', mockInput); + + expect(mockRepo.deactivateAllForUser).toHaveBeenCalledWith('u1', 'h1'); + expect(mockRepo.create).toHaveBeenCalledWith({ + ...mockInput, + userId: 'u1', + householdId: 'h1', + }); + expect(result).toEqual(createdTarget); + }); + + it('does NOT deactivate others if isActive is explicitly false', async () => { + const mockInput = { dailyCalories: 2000, proteinG: 100, carbsG: 200, fatG: 50, isActive: false }; + await service.setTarget('u1', 'h1', mockInput); + + expect(mockRepo.deactivateAllForUser).not.toHaveBeenCalled(); + expect(mockRepo.create).toHaveBeenCalled(); + }); + }); + + describe('calculatePreset', () => { + it('calculates macros correctly for maintenance (30p / 40c / 30f)', () => { + const result = service.calculatePreset(2000, 'maintenance'); + + // Math: + // Protein: (2000 * 0.3) / 4 = 600 / 4 = 150 + // Carbs: (2000 * 0.4) / 4 = 800 / 4 = 200 + // Fat: (2000 * 0.3) / 9 = 600 / 9 = 66.66 => 67 + expect(result).toEqual({ + dailyCalories: 2000, + proteinG: 150, + carbsG: 200, + fatG: 67, + isActive: true, + }); + }); + + it('calculates macros correctly for loss (40p / 30c / 30f)', () => { + const result = service.calculatePreset(2000, 'loss'); + + // Math: + // Protein: (2000 * 0.4) / 4 = 800 / 4 = 200 + // Carbs: (2000 * 0.3) / 4 = 600 / 4 = 150 + // Fat: (2000 * 0.3) / 9 = 600 / 9 = 66.66 => 67 + expect(result).toEqual({ + dailyCalories: 2000, + proteinG: 200, + carbsG: 150, + fatG: 67, + isActive: true, + }); + }); + + it('calculates macros correctly for gain (25p / 50c / 25f)', () => { + const result = service.calculatePreset(2000, 'gain'); + + // Math: + // Protein: (2000 * 0.25) / 4 = 500 / 4 = 125 + // Carbs: (2000 * 0.5) / 4 = 1000 / 4 = 250 + // Fat: (2000 * 0.25) / 9 = 500 / 9 = 55.55 => 56 + expect(result).toEqual({ + dailyCalories: 2000, + proteinG: 125, + carbsG: 250, + fatG: 56, + isActive: true, + }); + }); + }); +}); diff --git a/packages/api/src/modules/nutrition-targets/nutrition-target.service.ts b/packages/api/src/modules/nutrition-targets/nutrition-target.service.ts new file mode 100644 index 0000000..03e5901 --- /dev/null +++ b/packages/api/src/modules/nutrition-targets/nutrition-target.service.ts @@ -0,0 +1,85 @@ +import type { NutritionTargetRepository } from './nutrition-target.repository.js'; +import type { SetNutritionTargetInput } from '@meshitrack/shared'; + +interface Deps { + nutritionTargetRepository: NutritionTargetRepository; +} + +export type PresetType = 'maintenance' | 'loss' | 'gain'; + +export class NutritionTargetService { + private readonly nutritionTargetRepository: NutritionTargetRepository; + + public constructor({ nutritionTargetRepository }: Deps) { + this.nutritionTargetRepository = nutritionTargetRepository; + } + + public async getActiveByUser(userId: string, householdId: string) { + return this.nutritionTargetRepository.findByUser(userId, householdId); + } + + public async getAllByUser(userId: string, householdId: string) { + return this.nutritionTargetRepository.findAllByUser(userId, householdId); + } + + public async setTarget( + userId: string, + householdId: string, + input: SetNutritionTargetInput + ) { + // Maintain invariant: only one target is active per user per household + if (input.isActive !== false) { + await this.nutritionTargetRepository.deactivateAllForUser(userId, householdId); + } + + return this.nutritionTargetRepository.create({ + ...input, + userId, + householdId, + }); + } + + /** + * Calculate macros based on calorie goal and strategy preset. + * - Maintenance: 30% Protein, 40% Carbs, 30% Fat + * - Loss: 40% Protein, 30% Carbs, 30% Fat + * - Gain: 25% Protein, 50% Carbs, 25% Fat + */ + public calculatePreset(calories: number, preset: PresetType): SetNutritionTargetInput { + let proteinPct: number; + let carbsPct: number; + let fatPct: number; + + switch (preset) { + case 'loss': + proteinPct = 0.40; + carbsPct = 0.30; + fatPct = 0.30; + break; + case 'gain': + proteinPct = 0.25; + carbsPct = 0.50; + fatPct = 0.25; + break; + case 'maintenance': + default: + proteinPct = 0.30; + carbsPct = 0.40; + fatPct = 0.30; + break; + } + + // 4 kcal per gram of protein/carb, 9 kcal per gram of fat + const proteinG = Math.round((calories * proteinPct) / 4); + const carbsG = Math.round((calories * carbsPct) / 4); + const fatG = Math.round((calories * fatPct) / 9); + + return { + dailyCalories: calories, + proteinG, + carbsG, + fatG, + isActive: true, + }; + } +} diff --git a/packages/api/src/schemas/meal-plan.schema.ts b/packages/api/src/schemas/meal-plan.schema.ts new file mode 100644 index 0000000..18e30d1 --- /dev/null +++ b/packages/api/src/schemas/meal-plan.schema.ts @@ -0,0 +1,72 @@ +import mongoose from 'mongoose'; +import { MealType, MealPlanStatus } from '@meshitrack/shared'; + +const nutritionInfoSchema = new mongoose.Schema( + { + calories: { type: Number, required: true, min: 0 }, + protein: { type: Number, required: true, min: 0 }, + carbs: { type: Number, required: true, min: 0 }, + fat: { type: Number, required: true, min: 0 }, + fiber: { type: Number, min: 0 }, + sugar: { type: Number, min: 0 }, + sodium: { type: Number, min: 0 }, + saturatedFat: { type: Number, min: 0 }, + cholesterol: { type: Number, min: 0 }, + }, + { _id: false }, +); + +const plannedMealSchema = new mongoose.Schema( + { + id: { type: String, required: true }, // UUID for drag-and-drop reference + type: { type: String, enum: Object.values(MealType), required: true }, + recipeId: { type: String }, + recipeName: { type: String, required: true }, + servings: { type: Number, required: true, min: 0.1 }, + customName: { type: String }, + customNutrition: { type: nutritionInfoSchema }, + perServingNutrition: { type: nutritionInfoSchema, required: true }, + notes: { type: String }, + }, + { _id: false }, +); + +const mealPlanDaySchema = new mongoose.Schema( + { + date: { type: String, required: true }, // YYYY-MM-DD + meals: { type: [plannedMealSchema], default: [] }, + dailyNutritionTotal: { type: nutritionInfoSchema, required: true }, + }, + { _id: false }, +); + +const mealPlanSchema = new mongoose.Schema( + { + householdId: { type: String, required: true }, + weekStartDate: { type: String, required: true }, // Monday of the week YYYY-MM-DD + days: { type: [mealPlanDaySchema], required: true }, + status: { + type: String, + enum: Object.values(MealPlanStatus), + required: true, + default: MealPlanStatus.DRAFT, + }, + shoppingListId: { type: String }, + createdBy: { type: String, required: true }, + }, + { + timestamps: true, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + }, +); + +// Unique week per household +mealPlanSchema.index({ householdId: 1, weekStartDate: 1 }, { unique: true }); +mealPlanSchema.index({ householdId: 1, status: 1 }); +mealPlanSchema.index({ householdId: 1, 'days.meals.recipeId': 1 }); + +export const MealPlanModel = mongoose.model('MealPlan', mealPlanSchema); +export type MealPlanDocument = mongoose.InferSchemaType & { + _id: mongoose.Types.ObjectId; +}; diff --git a/packages/api/src/schemas/nutrition-target.schema.ts b/packages/api/src/schemas/nutrition-target.schema.ts new file mode 100644 index 0000000..171e547 --- /dev/null +++ b/packages/api/src/schemas/nutrition-target.schema.ts @@ -0,0 +1,29 @@ +import mongoose from 'mongoose'; + +const nutritionTargetSchema = new mongoose.Schema( + { + userId: { type: String, required: true }, + householdId: { type: String, required: true }, + dailyCalories: { type: Number, required: true, min: 0 }, + proteinG: { type: Number, required: true, min: 0 }, + carbsG: { type: Number, required: true, min: 0 }, + fatG: { type: Number, required: true, min: 0 }, + fiberG: { type: Number, min: 0 }, + sodiumMg: { type: Number, min: 0 }, + sugarG: { type: Number, min: 0 }, + isActive: { type: Boolean, required: true, default: true }, + }, + { + timestamps: true, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + }, +); + +// One active target per user per household +nutritionTargetSchema.index({ userId: 1, householdId: 1, isActive: 1 }); + +export const NutritionTargetModel = mongoose.model('NutritionTarget', nutritionTargetSchema); +export type NutritionTargetDocument = mongoose.InferSchemaType & { + _id: mongoose.Types.ObjectId; +}; diff --git a/packages/shared/src/enums/index.ts b/packages/shared/src/enums/index.ts index 55b5bb2..c5dd98a 100644 --- a/packages/shared/src/enums/index.ts +++ b/packages/shared/src/enums/index.ts @@ -8,3 +8,4 @@ export * from './purchase.enums.js'; export * from './product.enums.js'; export * from './recipe.enums.js'; export * from './pantry.enums.js'; +export * from './meal-plan.enums.js'; diff --git a/packages/shared/src/enums/meal-plan.enums.test.ts b/packages/shared/src/enums/meal-plan.enums.test.ts new file mode 100644 index 0000000..332524d --- /dev/null +++ b/packages/shared/src/enums/meal-plan.enums.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { MealType, MealPlanStatus } from './meal-plan.enums.js'; + +describe('MealPlan Enums', () => { + it('should have correct MealType values', () => { + expect(MealType.BREAKFAST).toBe('breakfast'); + expect(MealType.LUNCH).toBe('lunch'); + expect(MealType.DINNER).toBe('dinner'); + expect(MealType.SNACK).toBe('snack'); + }); + + it('should have correct MealPlanStatus values', () => { + expect(MealPlanStatus.DRAFT).toBe('draft'); + expect(MealPlanStatus.ACTIVE).toBe('active'); + expect(MealPlanStatus.COMPLETED).toBe('completed'); + }); +}); diff --git a/packages/shared/src/enums/meal-plan.enums.ts b/packages/shared/src/enums/meal-plan.enums.ts new file mode 100644 index 0000000..5114588 --- /dev/null +++ b/packages/shared/src/enums/meal-plan.enums.ts @@ -0,0 +1,18 @@ +/** + * Type of meal in a meal plan + */ +export enum MealType { + BREAKFAST = 'breakfast', + LUNCH = 'lunch', + DINNER = 'dinner', + SNACK = 'snack', +} + +/** + * Status of a weekly meal plan + */ +export enum MealPlanStatus { + DRAFT = 'draft', + ACTIVE = 'active', + COMPLETED = 'completed', +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 4b86de6..5321fae 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -14,4 +14,6 @@ export * from './purchase.js'; export * from './product.js'; export * from './recipe.js'; export * from './pantry.js'; +export * from './meal-plan.js'; +export * from './nutrition-target.js'; export * from './freshness.js'; diff --git a/packages/shared/src/types/meal-plan.ts b/packages/shared/src/types/meal-plan.ts new file mode 100644 index 0000000..1c25a6f --- /dev/null +++ b/packages/shared/src/types/meal-plan.ts @@ -0,0 +1,41 @@ +import type { NutritionInfo } from './product.js'; +import type { MealType, MealPlanStatus } from '../enums/meal-plan.enums.js'; + +export interface PlannedMeal { + id: string; // UUID for UI reference + type: MealType; + recipeId?: string; + recipeName: string; // Denormalized for display without full recipe fetch + servings: number; + customName?: string; + customNutrition?: NutritionInfo; + perServingNutrition: NutritionInfo; + notes?: string; +} + +export interface MealPlanDay { + date: string; // ISO date string (YYYY-MM-DD) + meals: PlannedMeal[]; + dailyNutritionTotal: NutritionInfo; // Computed field +} + +export interface MealPlan { + _id: string; + householdId: string; + weekStartDate: string; // Monday of the week (ISO date string YYYY-MM-DD) + days: MealPlanDay[]; + status: MealPlanStatus; + shoppingListId?: string; + createdBy: string; + createdAt: string; + updatedAt: string; +} + +export interface MealPlanResponse { + data: MealPlan[]; + pagination: { + cursor: string | null; + hasMore: boolean; + total: number; + }; +} diff --git a/packages/shared/src/types/nutrition-target.ts b/packages/shared/src/types/nutrition-target.ts new file mode 100644 index 0000000..d4a09f3 --- /dev/null +++ b/packages/shared/src/types/nutrition-target.ts @@ -0,0 +1,15 @@ +export interface NutritionTarget { + _id: string; + userId: string; + householdId: string; + dailyCalories: number; + proteinG: number; + carbsG: number; + fatG: number; + fiberG?: number; + sodiumMg?: number; + sugarG?: number; + isActive: boolean; + createdAt: string; + updatedAt: string; +} diff --git a/packages/shared/src/validation/index.ts b/packages/shared/src/validation/index.ts index 5a7cb4c..7b13254 100644 --- a/packages/shared/src/validation/index.ts +++ b/packages/shared/src/validation/index.ts @@ -10,6 +10,8 @@ export * from './medicine-price.schemas.js'; export * from './refill.schemas.js'; export * from './purchase.schemas.js'; export * from './product.schemas.js'; +export * from './meal-plan.schemas.js'; +export * from './nutrition-target.schemas.js'; export * from './recipe.schemas.js'; export * from './pantry.schemas.js'; export * from './freshness-rule.schemas.js'; diff --git a/packages/shared/src/validation/meal-plan.schemas.test.ts b/packages/shared/src/validation/meal-plan.schemas.test.ts new file mode 100644 index 0000000..de38875 --- /dev/null +++ b/packages/shared/src/validation/meal-plan.schemas.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { CreateMealPlanSchema } from './meal-plan.schemas.js'; +import { MealType, MealPlanStatus } from '../enums/meal-plan.enums.js'; + +describe('MealPlan Schemas', () => { + const validMeal = { + id: '550e8400-e29b-41d4-a716-446655440000', + type: MealType.DINNER, + recipeName: 'Chicken Salad', + servings: 2, + perServingNutrition: { calories: 300, protein: 30, carbs: 10, fat: 15 } + }; + + const validDay = { + date: '2026-05-18', + meals: [validMeal], + dailyNutritionTotal: { calories: 600, protein: 60, carbs: 20, fat: 30 } + }; + + const validPlan = { + weekStartDate: '2026-05-18', + days: Array(7).fill(validDay), + status: MealPlanStatus.DRAFT + }; + + it('should validate a valid meal plan', () => { + const result = CreateMealPlanSchema.safeParse(validPlan); + expect(result.success).toBe(true); + }); + + it('should reject a plan with missing days', () => { + const invalidPlan = { ...validPlan, days: validPlan.days.slice(0, 6) }; + const result = CreateMealPlanSchema.safeParse(invalidPlan); + expect(result.success).toBe(false); + }); + + it('should reject invalid date format', () => { + const invalidPlan = { ...validPlan, weekStartDate: '18-05-2026' }; + const result = CreateMealPlanSchema.safeParse(invalidPlan); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/shared/src/validation/meal-plan.schemas.ts b/packages/shared/src/validation/meal-plan.schemas.ts new file mode 100644 index 0000000..e1b4897 --- /dev/null +++ b/packages/shared/src/validation/meal-plan.schemas.ts @@ -0,0 +1,63 @@ +import { z } from 'zod/v4'; +import { MealType, MealPlanStatus } from '../enums/meal-plan.enums.js'; +import { NutritionInfoSchema } from './product.schemas.js'; + +export const PlannedMealSchema = z.object({ + id: z.string().uuid(), + type: z.nativeEnum(MealType), + recipeId: z.string().optional(), + recipeName: z.string().min(1), + servings: z.number().positive(), + customName: z.string().optional(), + customNutrition: NutritionInfoSchema.optional(), + perServingNutrition: NutritionInfoSchema, + notes: z.string().max(500).optional(), +}); + +export const MealPlanDaySchema = z.object({ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'), + meals: z.array(PlannedMealSchema), + dailyNutritionTotal: NutritionInfoSchema, +}); + +export const CreateMealPlanSchema = z.object({ + weekStartDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'), + days: z.array(MealPlanDaySchema).length(7), + status: z.nativeEnum(MealPlanStatus).default(MealPlanStatus.DRAFT), +}); + +export const UpdateMealPlanSchema = z.object({ + days: z.array(MealPlanDaySchema).length(7).optional(), + status: z.nativeEnum(MealPlanStatus).optional(), + shoppingListId: z.string().optional(), +}); + +export const MealPlanQuerySchema = z.object({ + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).default(20), +}); + +export const MealPlanResponseSchema = z.object({ + _id: z.string(), + householdId: z.string(), + weekStartDate: z.string(), + days: z.array(MealPlanDaySchema), + status: z.nativeEnum(MealPlanStatus), + shoppingListId: z.string().optional(), + createdBy: z.string(), + createdAt: z.string(), + updatedAt: z.string(), +}); + +export const MealPlanListResponseSchema = z.object({ + data: z.array(MealPlanResponseSchema), + pagination: z.object({ + cursor: z.string().nullable(), + hasMore: z.boolean(), + total: z.number().optional(), + }), +}); + +export type CreateMealPlanInput = z.infer; +export type UpdateMealPlanInput = z.infer; +export type MealPlanQueryInput = z.infer; diff --git a/packages/shared/src/validation/nutrition-target.schemas.test.ts b/packages/shared/src/validation/nutrition-target.schemas.test.ts new file mode 100644 index 0000000..fef5b53 --- /dev/null +++ b/packages/shared/src/validation/nutrition-target.schemas.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { NutritionTargetSchema } from './nutrition-target.schemas.js'; + +describe('NutritionTarget Schemas', () => { + const validTarget = { + dailyCalories: 2000, + proteinG: 150, + carbsG: 200, + fatG: 70, + isActive: true + }; + + it('should validate a valid target', () => { + const result = NutritionTargetSchema.safeParse(validTarget); + expect(result.success).toBe(true); + }); + + it('should reject negative macros', () => { + const invalidTarget = { ...validTarget, proteinG: -10 }; + const result = NutritionTargetSchema.safeParse(invalidTarget); + expect(result.success).toBe(false); + }); + + it('should reject zero calories', () => { + const invalidTarget = { ...validTarget, dailyCalories: 0 }; + const result = NutritionTargetSchema.safeParse(invalidTarget); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/shared/src/validation/nutrition-target.schemas.ts b/packages/shared/src/validation/nutrition-target.schemas.ts new file mode 100644 index 0000000..6564be3 --- /dev/null +++ b/packages/shared/src/validation/nutrition-target.schemas.ts @@ -0,0 +1,23 @@ +import { z } from 'zod/v4'; + +export const NutritionTargetSchema = z.object({ + dailyCalories: z.number().positive(), + proteinG: z.number().min(0), + carbsG: z.number().min(0), + fatG: z.number().min(0), + fiberG: z.number().min(0).optional(), + sodiumMg: z.number().min(0).optional(), + sugarG: z.number().min(0).optional(), + isActive: z.boolean().default(true), +}); + +export const NutritionTargetResponseSchema = NutritionTargetSchema.extend({ + _id: z.string(), + userId: z.string(), + householdId: z.string(), + createdAt: z.string(), + updatedAt: z.string(), +}); + +export type SetNutritionTargetInput = z.infer; +export type NutritionTargetResponse = z.infer; diff --git a/packages/web/src/app/(dashboard)/meal-plans/page.tsx b/packages/web/src/app/(dashboard)/meal-plans/page.tsx new file mode 100644 index 0000000..12a51bc --- /dev/null +++ b/packages/web/src/app/(dashboard)/meal-plans/page.tsx @@ -0,0 +1,1062 @@ +'use client'; + +import React, { useState, useEffect, useMemo } from 'react'; +import useSWR from 'swr'; +import { useApi } from '@/lib/useApi'; +import { SetPageHeader } from '@/components/layout/SetPageHeader'; +import { + getMealPlanByWeek, + createMealPlan, + updateMealPlan, + getSuggestions, + getShoppingGap, + updateMealPlanStatus, + MealPlanResponse, +} from '@/services/meal-plans'; +import { + getActiveNutritionTarget, + setNutritionTarget, + calculateTargetPreset, + NutritionTargetResponse, +} from '@/services/nutrition-targets'; +import { listRecipes } from '@/services/recipes'; +import { MealType, MealPlanStatus } from '@meshitrack/shared'; +import { Icon } from '@/components/ui/Icon'; + +// --- Utility Helpers --- +function getMonday(d: Date): Date { + const date = new Date(d); + const day = date.getDay(); + const diff = date.getDate() - day + (day === 0 ? -6 : 1); + const mon = new Date(date.setDate(diff)); + mon.setHours(0, 0, 0, 0); + return mon; +} + +function toIsoDate(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const date = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${date}`; +} + +function addDays(d: Date, days: number): Date { + const copy = new Date(d); + copy.setDate(copy.getDate() + days); + return copy; +} + +function formatNiceDate(iso: string): string { + const [y, m, d] = iso.split('-').map(Number); + // Adjusting for locale timezone bias + const date = new Date(y, m - 1, d); + return date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); +} + +const MEAL_TYPES = [MealType.BREAKFAST, MealType.LUNCH, MealType.DINNER, MealType.SNACK]; + +// Standard blank nutrition structure +const BLANK_NUTRITION = { + calories: 0, + protein: 0, + carbs: 0, + fat: 0, + fiber: 0, + sugar: 0, + sodium: 0, +}; + +export default function MealPlansPage() { + const { householdId, isLoading } = useApi(); + const [currentMonday, setCurrentMonday] = useState(() => getMonday(new Date())); + const weekStr = useMemo(() => toIsoDate(currentMonday), [currentMonday]); + + if (isLoading) { + return ( + <> + +
+
+
+
+ + ); + } + + if (!householdId) { + return ( + <> + +
+
+

Please join a household to access the meal planner.

+
+
+ + ); + } + + return ( + <> + + + + ); +} + +interface DashboardProps { + householdId: string; + currentMonday: Date; + setCurrentMonday: React.Dispatch>; + weekStr: string; +} + +function MealPlannerDashboard({ householdId, currentMonday, setCurrentMonday, weekStr }: DashboardProps) { + const [isGoalModalOpen, setIsGoalModalOpen] = useState(false); + const [activeRightTab, setActiveRightTab] = useState<'suggestions' | 'gap'>('suggestions'); + const [isAddingMeal, setIsAddingMeal] = useState<{ date: string; type: MealType } | null>(null); + + // --- Data Fetchers via SWR --- + const { data: targetData, mutate: mutateTarget } = useSWR( + householdId ? ['nutrition-targets', householdId] : null, + () => getActiveNutritionTarget(householdId) + ); + + const activeTarget: NutritionTargetResponse | null = useMemo(() => { + if (!targetData || 'message' in targetData) return null; + return targetData; + }, [targetData]); + + const { data: planData, mutate: mutatePlan, isLoading: planLoading } = useSWR( + householdId && weekStr ? ['meal-plan', householdId, weekStr] : null, + () => getMealPlanByWeek(householdId, weekStr) + ); + + const currentPlan: MealPlanResponse | null = useMemo(() => { + if (!planData || 'message' in planData) return null; + return planData; + }, [planData]); + + const { data: recipeList } = useSWR( + householdId ? ['recipes', householdId] : null, + () => listRecipes(householdId, { limit: 100 }) + ); + + const { data: suggestions } = useSWR( + householdId ? ['suggestions', householdId] : null, + () => getSuggestions(householdId, 5) + ); + + const { data: gapReport, mutate: mutateGap } = useSWR( + householdId && currentPlan?._id ? ['gap', householdId, currentPlan._id] : null, + () => getShoppingGap(householdId, currentPlan!._id) + ); + + // --- Day Schedule Calculations --- + // Always ensure we have representations for the 7 days even if no plan doc exists yet. + const weeklyDays = useMemo(() => { + const arr = []; + for (let i = 0; i < 7; i++) { + const d = addDays(currentMonday, i); + const dStr = toIsoDate(d); + const planDay = currentPlan?.days.find((x) => x.date === dStr); + arr.push({ + date: dStr, + meals: planDay?.meals || [], + dailyNutritionTotal: planDay?.dailyNutritionTotal || BLANK_NUTRITION, + }); + } + return arr; + }, [currentMonday, currentPlan]); + + // --- Action Event Handlers --- + const prevWeek = () => setCurrentMonday((m) => addDays(m, -7)); + const nextWeek = () => setCurrentMonday((m) => addDays(m, 7)); + const jumpToToday = () => setCurrentMonday(getMonday(new Date())); + + async function handleCreatePlan() { + const initialDays = []; + for (let i = 0; i < 7; i++) { + initialDays.push({ + date: toIsoDate(addDays(currentMonday, i)), + meals: [], + dailyNutritionTotal: BLANK_NUTRITION, + }); + } + + try { + await createMealPlan(householdId, { + weekStartDate: weekStr, + days: initialDays, + status: MealPlanStatus.DRAFT, + }); + mutatePlan(); + } catch (err) { + console.error('Failed to init weekly plan', err); + } + } + + async function handleAddMeal(recipeId: string, servings: number) { + if (!isAddingMeal) return; + const targetRecipe = recipeList?.data.find((r) => r._id === recipeId); + if (!targetRecipe) return; + + const newMeal = { + id: crypto.randomUUID(), + type: isAddingMeal.type, + recipeId: targetRecipe._id, + recipeName: targetRecipe.name, + servings, + perServingNutrition: targetRecipe.perServingNutrition, + }; + + let workingPlan = currentPlan; + if (!workingPlan) { + const initialDays = []; + for (let i = 0; i < 7; i++) { + initialDays.push({ + date: toIsoDate(addDays(currentMonday, i)), + meals: [], + dailyNutritionTotal: BLANK_NUTRITION, + }); + } + workingPlan = await createMealPlan(householdId, { + weekStartDate: weekStr, + days: initialDays, + status: MealPlanStatus.DRAFT, + }); + } + + const updatedDays = workingPlan.days.map((day) => { + if (day.date === isAddingMeal.date) { + return { + ...day, + meals: [...day.meals, newMeal], + }; + } + return day; + }); + + try { + await updateMealPlan(householdId, workingPlan._id, { days: updatedDays as any }); + mutatePlan(); + mutateGap(); + setIsAddingMeal(null); + } catch (err) { + console.error('Failed to add meal', err); + } + } + + async function handleRemoveMeal(dayDate: string, mealId: string) { + if (!currentPlan) return; + + const updatedDays = currentPlan.days.map((day) => { + if (day.date === dayDate) { + return { + ...day, + meals: day.meals.filter((m) => m.id !== mealId), + }; + } + return day; + }); + + try { + await updateMealPlan(householdId, currentPlan._id, { days: updatedDays as any }); + mutatePlan(); + mutateGap(); + } catch (err) { + console.error('Failed to remove meal', err); + } + } + + async function handleToggleStatus() { + if (!currentPlan) return; + const nextStatus = + currentPlan.status === MealPlanStatus.DRAFT ? MealPlanStatus.ACTIVE : MealPlanStatus.COMPLETED; + try { + await updateMealPlanStatus(householdId, currentPlan._id, nextStatus); + mutatePlan(); + } catch (err) { + console.error('Status toggle failed', err); + } + } + + // --- Drag and Drop Functionality --- + const [dragOverDay, setDragOverDay] = useState(null); + + function handleDragStart(e: React.DragEvent, sourceDayStr: string, mealId: string) { + e.dataTransfer.setData('text/plain', JSON.stringify({ sourceDayStr, mealId })); + e.dataTransfer.effectAllowed = 'move'; + } + + async function handleDrop(e: React.DragEvent, targetDayStr: string) { + e.preventDefault(); + setDragOverDay(null); + if (!currentPlan) return; + + try { + const data = JSON.parse(e.dataTransfer.getData('text/plain')); + const { sourceDayStr, mealId } = data; + if (sourceDayStr === targetDayStr) return; + + // Find source meal + const sourceDay = currentPlan.days.find((d) => d.date === sourceDayStr); + const mealToMove = sourceDay?.meals.find((m) => m.id === mealId); + if (!mealToMove) return; + + const updatedDays = currentPlan.days.map((day) => { + if (day.date === sourceDayStr) { + return { ...day, meals: day.meals.filter((m) => m.id !== mealId) }; + } + if (day.date === targetDayStr) { + return { ...day, meals: [...day.meals, mealToMove] }; + } + return day; + }); + + // Optimistic mutate + mutatePlan({ ...currentPlan, days: updatedDays as any }, false); + await updateMealPlan(householdId, currentPlan._id, { days: updatedDays as any }); + mutatePlan(); + mutateGap(); + } catch (err) { + console.error('Drop processing failed', err); + } + } + + return ( +
+ {/* --- Top Navigation Bar --- */} +
+
+ +
+ + Week of {formatNiceDate(weekStr)} + +
+ + +
+ + {/* Target summary card (glassmorphism feel) */} +
setIsGoalModalOpen(true)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 16, + background: 'var(--bg-elev)', + border: '1px solid var(--border)', + padding: '8px 16px', + borderRadius: 'var(--r-md)', + cursor: 'pointer', + transition: 'all 0.2s', + boxShadow: 'var(--shadow-sm)', + }} + onMouseEnter={(e) => (e.currentTarget.style.transform = 'translateY(-1px)')} + onMouseLeave={(e) => (e.currentTarget.style.transform = 'none')} + > + +
+ + Nutrition Strategy + + + {activeTarget ? ( + <> + {activeTarget.dailyCalories} kcal + | + + {activeTarget.proteinG}g P · {activeTarget.carbsG}g C · {activeTarget.fatG}g F + + + ) : ( + Set Macro Goals + + )} + +
+
+ +
+ {currentPlan ? ( + <> +
+ {currentPlan.status} +
+ + + ) : ( + + )} +
+
+ + {/* --- Workspace Layout --- */} +
+ {/* --- LEFT COLUMN: Calendar view --- */} +
+ {planLoading ? ( +
+ ) : ( + weeklyDays.map((day) => { + const isOverTarget = activeTarget && day.dailyNutritionTotal.calories > activeTarget.dailyCalories; + const calPercentage = activeTarget + ? Math.min(Math.round((day.dailyNutritionTotal.calories / activeTarget.dailyCalories) * 100), 100) + : 0; + + return ( +
{ + e.preventDefault(); + setDragOverDay(day.date); + }} + onDragLeave={() => setDragOverDay(null)} + onDrop={(e) => handleDrop(e, day.date)} + style={{ + background: dragOverDay === day.date ? 'var(--brand-soft)' : 'var(--bg-elev)', + border: `1px solid ${dragOverDay === day.date ? 'var(--brand)' : 'var(--border)'}`, + borderRadius: 'var(--r-md)', + padding: '16px 20px', + boxShadow: 'var(--shadow-sm)', + transition: 'background 0.15s ease, border 0.15s ease', + }} + > + {/* Day Title Strip */} +
+
+
+ {formatNiceDate(day.date)} +
+
+ + {/* Calibration progress bar */} + {activeTarget && ( +
+
+ + + {day.dailyNutritionTotal.calories} + {' '} + / {activeTarget.dailyCalories} kcal + +
+
+
+
+
+ )} +
+ + {/* Meal Row Slots */} +
+ {MEAL_TYPES.map((type) => { + const mealsForType = day.meals.filter((m) => m.type === type); + + return ( +
+
+ {type} +
+ + {mealsForType.map((meal) => ( +
handleDragStart(e, day.date, meal.id)} + style={{ + background: 'var(--bg-elev)', + border: '1px solid var(--border)', + borderRadius: 'var(--r-xs)', + padding: '6px 8px', + cursor: 'grab', + position: 'relative', + boxShadow: 'var(--shadow-sm)', + }} + > + +
+ {meal.recipeName} +
+
+ {meal.servings} svc + + {Math.round(meal.perServingNutrition.calories * meal.servings)} cal + +
+
+ ))} + + {/* Quick inline Add button */} + +
+ ); + })} +
+
+ ); + }) + )} +
+ + {/* --- RIGHT COLUMN: AI Suggestions & Live Shopping Gap Tabs --- */} +
+
+ {/* Glass tab heads */} +
+ {(['suggestions', 'gap'] as const).map((tab) => ( + + ))} +
+ + {/* Tab content viewport */} +
+ {activeRightTab === 'suggestions' && ( +
+
+ Smart Ranking evaluates expiring pantry items, macro balances, and historical meal diversity. +
+ + {!suggestions || suggestions.length === 0 ? ( +
+ No dynamic recommendations yet. Add inventory and recipes to spark. +
+ ) : ( + suggestions.map((s) => ( +
+
+ + {s.recipeName} + + + {Math.round(s.totalScore)}% Match + +
+ + {/* Tiny reasoning badges */} +
+ {s.reasoning.map((r, idx) => ( + + {r} + + ))} +
+
+ )) + )} +
+ )} + + {activeRightTab === 'gap' && ( +
+
+ Contrasts quantities needed to cook scheduled recipes against active pantry inventory levels. +
+ + {!currentPlan ? ( +
+ Initialize a plan to run inventory analyses. +
+ ) : !gapReport || gapReport.missingItems.length === 0 ? ( +
+ 🎉 fully stocked! All required ingredients exist in your pantry. +
+ ) : ( +
+ {gapReport.missingItems.map((item) => ( +
+
+ + {item.productName} + + + Need: {item.requiredQuantity} {item.unit} · Stock: {item.pantryQuantity} + +
+
+ +{Math.ceil(item.missingQuantity)} {item.unit} +
+
+ ))} +
+ )} +
+ )} +
+
+
+
+ + {/* --- NUTRITION STRATEGY SETTINGS MODAL --- */} + {isGoalModalOpen && ( + setIsGoalModalOpen(false)} + onSaved={() => { + mutateTarget(); + setIsGoalModalOpen(false); + }} + /> + )} + + {/* --- QUICK MEAL SELECTOR MODAL --- */} + {isAddingMeal && ( + setIsAddingMeal(null)} + onConfirm={handleAddMeal} + /> + )} +
+ ); +} + +// --- Nutrition Strategy Customizer --- +interface NutritionGoalModalProps { + householdId: string; + activeTarget: NutritionTargetResponse | null; + onClose: () => void; + onSaved: () => void; +} + +function NutritionGoalModal({ householdId, activeTarget, onClose, onSaved }: NutritionGoalModalProps) { + const [cals, setCals] = useState(activeTarget?.dailyCalories || 2000); + const [pG, setPG] = useState(activeTarget?.proteinG || 150); + const [cG, setCG] = useState(activeTarget?.carbsG || 200); + const [fG, setFG] = useState(activeTarget?.fatG || 67); + const [isSaving, setIsSaving] = useState(false); + + async function handleApplyPreset(strategy: 'maintenance' | 'loss' | 'gain') { + try { + const split = await calculateTargetPreset(householdId, cals, strategy); + setPG(split.proteinG); + setCG(split.carbsG); + setFG(split.fatG); + } catch (err) { + console.error('Preset calc failed', err); + } + } + + async function handleSave() { + setIsSaving(true); + try { + await setNutritionTarget(householdId, { + dailyCalories: cals, + proteinG: pG, + carbsG: cG, + fatG: fG, + isActive: true, + }); + onSaved(); + } catch (err) { + console.error('Failed saving targets', err); + } finally { + setIsSaving(false); + } + } + + return ( +
+
+
+ Nutrition Strategy + +
+ +
+ {/* Calorie Target Input */} +
+ + setCals(Number(e.target.value))} + /> +
+ + {/* Quick Splits Presets */} +
+ +
+ + + +
+
+ +
+ + {/* Granular Macro overrides */} +
+
+ + setPG(Number(e.target.value))} /> +
+
+ + setCG(Number(e.target.value))} /> +
+
+ + setFG(Number(e.target.value))} /> +
+
+ + {/* Math validation strip */} +
+ Parsed splits sum: + + {pG * 4 + cG * 4 + fG * 9} kcal + +
+ + {/* Foot Controls */} +
+ + +
+
+
+
+ ); +} + +// --- Quick Meal Selector Overlay --- +interface AddMealModalProps { + recipes: any[]; + dateStr: string; + mealType: MealType; + onClose: () => void; + onConfirm: (recipeId: string, servings: number) => void; +} + +function AddMealModal({ recipes, dateStr, mealType, onClose, onConfirm }: AddMealModalProps) { + const [sel, setSel] = useState(''); + const [sv, setSv] = useState(1); + + function handleConfirm() { + if (!sel) return; + onConfirm(sel, sv); + } + + return ( +
+
+
+ + Schedule {mealType} on {formatNiceDate(dateStr)} + + +
+ +
+
+ + +
+ +
+ + setSv(Number(e.target.value))} + /> +
+ +
+ + +
+
+
+
+ ); +} diff --git a/packages/web/src/components/layout/Sidebar.tsx b/packages/web/src/components/layout/Sidebar.tsx index 1abd05d..ef7e1af 100644 --- a/packages/web/src/components/layout/Sidebar.tsx +++ b/packages/web/src/components/layout/Sidebar.tsx @@ -71,6 +71,13 @@ const NAV: NavItem[] = [ section: 'Medicines', }, { id: 'settings', label: 'Settings', href: '/settings', icon: 'settings' }, + { + id: 'meal-plans', + label: 'Meal Planner', + href: '/meal-plans', + icon: 'calendar', + section: 'Food', + }, { id: 'recipes', label: 'Recipes', diff --git a/packages/web/src/components/ui/Icon.tsx b/packages/web/src/components/ui/Icon.tsx index fb108d1..2eb1444 100644 --- a/packages/web/src/components/ui/Icon.tsx +++ b/packages/web/src/components/ui/Icon.tsx @@ -34,7 +34,10 @@ export type IconName = | 'fridge' | 'box' | 'yen' - | 'zap'; + | 'zap' + | 'chevronLeft' + | 'chevronRight' + | 'target'; interface IconProps { name: IconName; @@ -206,6 +209,15 @@ const PATHS: Record = { ), yen: , zap: , + chevronLeft: , + chevronRight: , + target: ( + <> + + + + + ), }; const svgProps: Omit, 'width' | 'height'> = { diff --git a/packages/web/src/services/meal-plans.ts b/packages/web/src/services/meal-plans.ts new file mode 100644 index 0000000..1b9f432 --- /dev/null +++ b/packages/web/src/services/meal-plans.ts @@ -0,0 +1,131 @@ +import { apiClient } from './api-client'; +import type { z } from 'zod/v4'; +import type { + MealPlanResponseSchema, + MealPlanListResponseSchema, + CreateMealPlanInput, + UpdateMealPlanInput, + MealPlanStatus, +} from '@meshitrack/shared'; + +export type MealPlanResponse = z.infer; +export type MealPlanListResponse = z.infer; + +export interface MealPlanQuery { + cursor?: string; + limit?: number; +} + +export interface RecipeSuggestion { + recipeId: string; + recipeName: string; + totalScore: number; + scores: { + coverage: number; + urgency: number; + nutrition: number; + variety: number; + }; + reasoning: string[]; +} + +export interface ShoppingGapReport { + mealPlanId: string; + missingItems: Array<{ + productId: string; + productName: string; + category: string; + requiredQuantity: number; + pantryQuantity: number; + missingQuantity: number; + unit: string; + }>; +} + +export async function listMealPlans( + householdId: string, + query?: MealPlanQuery +): Promise { + const params = new URLSearchParams(); + if (query?.cursor) params.set('cursor', query.cursor); + if (query?.limit) params.set('limit', String(query.limit)); + const qs = params.toString(); + return apiClient.get( + `/households/${householdId}/meal-plans${qs ? `?${qs}` : ''}` + ); +} + +export async function getMealPlanByWeek( + householdId: string, + weekStartDate: string +): Promise { + return apiClient.get( + `/households/${householdId}/meal-plans/week/${weekStartDate}` + ); +} + +export async function getMealPlan( + householdId: string, + id: string +): Promise { + return apiClient.get( + `/households/${householdId}/meal-plans/${id}` + ); +} + +export async function createMealPlan( + householdId: string, + data: CreateMealPlanInput +): Promise { + return apiClient.post( + `/households/${householdId}/meal-plans`, + data + ); +} + +export async function updateMealPlan( + householdId: string, + id: string, + data: UpdateMealPlanInput +): Promise { + return apiClient.patch( + `/households/${householdId}/meal-plans/${id}`, + data + ); +} + +export async function updateMealPlanStatus( + householdId: string, + id: string, + status: MealPlanStatus +): Promise { + return apiClient.patch( + `/households/${householdId}/meal-plans/${id}/status`, + { status } + ); +} + +export async function deleteMealPlan( + householdId: string, + id: string +): Promise { + return apiClient.delete(`/households/${householdId}/meal-plans/${id}`); +} + +export async function getSuggestions( + householdId: string, + limit = 5 +): Promise { + return apiClient.get( + `/households/${householdId}/meal-plans/suggestions?limit=${limit}` + ); +} + +export async function getShoppingGap( + householdId: string, + id: string +): Promise { + return apiClient.get( + `/households/${householdId}/meal-plans/${id}/gap` + ); +} diff --git a/packages/web/src/services/nutrition-targets.ts b/packages/web/src/services/nutrition-targets.ts new file mode 100644 index 0000000..cb5f558 --- /dev/null +++ b/packages/web/src/services/nutrition-targets.ts @@ -0,0 +1,45 @@ +import { apiClient } from './api-client'; +import type { z } from 'zod/v4'; +import type { + NutritionTargetResponseSchema, + SetNutritionTargetInput, +} from '@meshitrack/shared'; + +export type NutritionTargetResponse = z.infer; + +export async function getActiveNutritionTarget( + householdId: string +): Promise { + return apiClient.get( + `/households/${householdId}/nutrition-targets` + ); +} + +export async function getNutritionTargetHistory( + householdId: string +): Promise { + return apiClient.get( + `/households/${householdId}/nutrition-targets/history` + ); +} + +export async function setNutritionTarget( + householdId: string, + data: SetNutritionTargetInput +): Promise { + return apiClient.post( + `/households/${householdId}/nutrition-targets`, + data + ); +} + +export async function calculateTargetPreset( + householdId: string, + calories: number, + strategy: 'maintenance' | 'loss' | 'gain' +): Promise { + return apiClient.post( + `/households/${householdId}/nutrition-targets/presets`, + { calories, strategy } + ); +}