This commit is contained in:
Aerilyn Weber 2026-05-14 18:38:50 +09:00
parent 029940b079
commit e396f5088c
36 changed files with 4199 additions and 22 deletions

View file

@ -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)"
]
}
}

View file

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

View file

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

View file

@ -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<string, unknown> = { 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<string, unknown>) {
const doc = new MealPlanModel(data);
const saved = await doc.save();
return saved.toObject();
}
public async update(id: string, householdId: string, data: Record<string, unknown>) {
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();
}
}

View file

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

View file

@ -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<typeof MealPlanResponseSchema> {
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<ZodTypeProvider>();
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'],
}
);

View file

@ -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<typeof vi.fn> };
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' });
});
});
});

View file

@ -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<typeof MealPlanDaySchema>;
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<string, unknown> = {};
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,
},
};
}
}

View file

@ -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<typeof vi.fn> };
let mockRecipesRepo: { [K in keyof RecipesRepository]: ReturnType<typeof vi.fn> };
let mockPantryRepo: { [K in keyof PantryRepository]: ReturnType<typeof vi.fn> };
let mockProductsRepo: { [K in keyof ProductsRepository]: ReturnType<typeof vi.fn> };
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);
});
});
});

View file

@ -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<ShoppingGapResult> {
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<string>();
const plannedMealsList: Array<{ recipeId: string; plannedServings: number }> = [];
const recPlan = plan as Record<string, unknown>;
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<string, any>();
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<string, { qty: number; unit: string }>();
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<string, any>();
for (const p of productDocs) {
productsInfoMap.set(p._id.toString(), p);
}
// Aggregate current Pantry items by productId
const pantryMap = new Map<string, number>();
for (const item of activePantry) {
const rec = item as Record<string, unknown>;
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)),
};
}
}

View file

@ -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<typeof vi.fn> };
let mockPantryRepo: { [K in keyof PantryRepository]: ReturnType<typeof vi.fn> };
let mockMealPlanRepo: { [K in keyof MealPlanRepository]: ReturnType<typeof vi.fn> };
let mockNutritionRepo: { [K in keyof NutritionTargetRepository]: ReturnType<typeof vi.fn> };
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);
});
});
});

View file

@ -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<ScoredRecipe[]> {
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<string, unknown>;
const productId = rec.productId as string;
const quantity = (rec.quantity as number) ?? 0;
const fresh = (rec.freshnessEstimate ?? {}) as Record<string, unknown>;
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<string, number>(); // recipeId -> daysAgo
const now = new Date();
for (const plan of recentPlansResult.data) {
const recPlan = plan as Record<string, unknown>;
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<string, unknown>;
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<string, number> | undefined,
recipeId,
pantryInventory,
activeTarget as Record<string, unknown> | 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<string, number> | undefined,
recipeId: string,
inventory: Map<string, { qty: number; minDays: number; maxUrgencyWeight: number }>,
target: Record<string, unknown> | null,
lastEaten: Map<string, number>
) {
// 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;
}
}

View file

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

View file

@ -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<string, unknown>) {
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<string, unknown>) {
return NutritionTargetModel.findOneAndUpdate(
{ _id: id, userId, householdId },
{ $set: data },
{ new: true, lean: true }
).exec();
}
}

View file

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

View file

@ -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<typeof NutritionTargetResponseSchema> {
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<ZodTypeProvider>();
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'],
}
);

View file

@ -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<typeof vi.fn> };
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,
});
});
});
});

View file

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

View file

@ -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<typeof mealPlanSchema> & {
_id: mongoose.Types.ObjectId;
};

View file

@ -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<typeof nutritionTargetSchema> & {
_id: mongoose.Types.ObjectId;
};

View file

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

View file

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

View file

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

View file

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

View file

@ -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;
};
}

View file

@ -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;
}

View file

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

View file

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

View file

@ -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<typeof CreateMealPlanSchema>;
export type UpdateMealPlanInput = z.infer<typeof UpdateMealPlanSchema>;
export type MealPlanQueryInput = z.infer<typeof MealPlanQuerySchema>;

View file

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

View file

@ -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<typeof NutritionTargetSchema>;
export type NutritionTargetResponse = z.infer<typeof NutritionTargetResponseSchema>;

File diff suppressed because it is too large Load diff

View file

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

View file

@ -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<IconName, React.ReactNode> = {
),
yen: <path d="M5 4l7 9 7-9M7 13h10M7 17h10M12 13v7" />,
zap: <path d="M13 2L4 14h7l-1 8 9-12h-7z" />,
chevronLeft: <path d="M15 18l-6-6 6-6" />,
chevronRight: <path d="M9 18l6-6-6-6" />,
target: (
<>
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="6" />
<circle cx="12" cy="12" r="2" />
</>
),
};
const svgProps: Omit<SVGProps<SVGSVGElement>, 'width' | 'height'> = {

View file

@ -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<typeof MealPlanResponseSchema>;
export type MealPlanListResponse = z.infer<typeof MealPlanListResponseSchema>;
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<MealPlanListResponse> {
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<MealPlanListResponse>(
`/households/${householdId}/meal-plans${qs ? `?${qs}` : ''}`
);
}
export async function getMealPlanByWeek(
householdId: string,
weekStartDate: string
): Promise<MealPlanResponse | { message: string }> {
return apiClient.get<MealPlanResponse | { message: string }>(
`/households/${householdId}/meal-plans/week/${weekStartDate}`
);
}
export async function getMealPlan(
householdId: string,
id: string
): Promise<MealPlanResponse> {
return apiClient.get<MealPlanResponse>(
`/households/${householdId}/meal-plans/${id}`
);
}
export async function createMealPlan(
householdId: string,
data: CreateMealPlanInput
): Promise<MealPlanResponse> {
return apiClient.post<MealPlanResponse>(
`/households/${householdId}/meal-plans`,
data
);
}
export async function updateMealPlan(
householdId: string,
id: string,
data: UpdateMealPlanInput
): Promise<MealPlanResponse> {
return apiClient.patch<MealPlanResponse>(
`/households/${householdId}/meal-plans/${id}`,
data
);
}
export async function updateMealPlanStatus(
householdId: string,
id: string,
status: MealPlanStatus
): Promise<MealPlanResponse> {
return apiClient.patch<MealPlanResponse>(
`/households/${householdId}/meal-plans/${id}/status`,
{ status }
);
}
export async function deleteMealPlan(
householdId: string,
id: string
): Promise<void> {
return apiClient.delete(`/households/${householdId}/meal-plans/${id}`);
}
export async function getSuggestions(
householdId: string,
limit = 5
): Promise<RecipeSuggestion[]> {
return apiClient.get<RecipeSuggestion[]>(
`/households/${householdId}/meal-plans/suggestions?limit=${limit}`
);
}
export async function getShoppingGap(
householdId: string,
id: string
): Promise<ShoppingGapReport> {
return apiClient.get<ShoppingGapReport>(
`/households/${householdId}/meal-plans/${id}/gap`
);
}

View file

@ -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<typeof NutritionTargetResponseSchema>;
export async function getActiveNutritionTarget(
householdId: string
): Promise<NutritionTargetResponse | { message: string }> {
return apiClient.get<NutritionTargetResponse | { message: string }>(
`/households/${householdId}/nutrition-targets`
);
}
export async function getNutritionTargetHistory(
householdId: string
): Promise<NutritionTargetResponse[]> {
return apiClient.get<NutritionTargetResponse[]>(
`/households/${householdId}/nutrition-targets/history`
);
}
export async function setNutritionTarget(
householdId: string,
data: SetNutritionTargetInput
): Promise<NutritionTargetResponse> {
return apiClient.post<NutritionTargetResponse>(
`/households/${householdId}/nutrition-targets`,
data
);
}
export async function calculateTargetPreset(
householdId: string,
calories: number,
strategy: 'maintenance' | 'loss' | 'gain'
): Promise<SetNutritionTargetInput> {
return apiClient.post<SetNutritionTargetInput>(
`/households/${householdId}/nutrition-targets/presets`,
{ calories, strategy }
);
}