diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 58d805b..c185902 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -9,7 +9,10 @@ import { Sentry } from './lib/sentry.js'; import { dbRoutes } from './modules/db/db.routes.js'; import { healthRoutes } from './modules/health/health.routes.js'; import { meRoutes } from './modules/me/me.routes.js'; +import { planRoutes } from './modules/plan/plan.routes.js'; import { profileRoutes } from './modules/profile/profile.routes.js'; +import { fastRoutes } from './modules/tracking/fast.routes.js'; +import { weightRoutes } from './modules/tracking/weight.routes.js'; import { authPlugin } from './plugins/auth.js'; import { prismaPlugin } from './plugins/prisma.js'; @@ -46,6 +49,9 @@ export async function buildApp(): Promise { await app.register(dbRoutes); await app.register(meRoutes); await app.register(profileRoutes); + await app.register(weightRoutes); + await app.register(fastRoutes); + await app.register(planRoutes); if (env.SENTRY_DSN) { app.setErrorHandler((err, request, reply) => { diff --git a/apps/api/src/modules/plan/plan.routes.ts b/apps/api/src/modules/plan/plan.routes.ts new file mode 100644 index 0000000..29f8340 --- /dev/null +++ b/apps/api/src/modules/plan/plan.routes.ts @@ -0,0 +1,157 @@ +import { + DEFAULT_MEAL_SHARE, + calculateBmr, + calculateTdee, + macrosForPhase, + matchMeals, + type RecipeCandidate, +} from '@ketopath/shared'; +import type { FastifyPluginAsync } from 'fastify'; + +import { requireAuth } from '../../plugins/auth.js'; + +const MEALS = ['COLAZIONE', 'PRANZO', 'SPUNTINO', 'CENA'] as const; + +function startOfWeek(d: Date): Date { + const out = new Date(d); + const day = out.getDay(); // 0 = Sunday + const diff = day === 0 ? 6 : day - 1; // Monday-anchored week + out.setDate(out.getDate() - diff); + out.setHours(0, 0, 0, 0); + return out; +} + +function phaseToInt(p: string): 1 | 2 | 3 { + if (p === 'INTENSIVE') return 1; + if (p === 'TRANSITION') return 2; + return 3; +} + +export const planRoutes: FastifyPluginAsync = async (fastify) => { + fastify.post('/me/meal-plans', { preHandler: requireAuth() }, async (request, reply) => { + const userId = request.user!.id; + + const profile = await fastify.prisma.profile.findUnique({ + where: { userId }, + include: { user: { include: { preferences: true } } }, + }); + if (!profile) return reply.code(409).send({ error: 'profile_required' }); + + const recipes = await fastify.prisma.recipe.findMany({ + select: { + id: true, + name: true, + category: true, + kcal: true, + proteinG: true, + fatG: true, + netCarbG: true, + phases: true, + }, + }); + if (recipes.length === 0) { + return reply.code(409).send({ error: 'recipe_catalog_empty' }); + } + + const candidates: RecipeCandidate[] = recipes.map((r) => ({ + id: r.id, + name: r.name, + category: r.category, + kcal: r.kcal, + proteinG: r.proteinG, + fatG: r.fatG, + netCarbG: r.netCarbG, + exclusionTags: [], + phases: r.phases.filter((p): p is 1 | 2 | 3 => p === 1 || p === 2 || p === 3), + })); + + const weightCurrentKg = Number(profile.weightCurrentKg); + const bmr = calculateBmr({ + weightKg: weightCurrentKg, + heightCm: profile.heightCm, + ageYears: profile.age, + gender: profile.gender, + }); + const tdee = calculateTdee(bmr, profile.activityLevel); + const phaseInt = phaseToInt(profile.currentPhase); + // Deficit base solo in fase 1; in fase 2/3 si avvicina a TDEE. + const kcalTarget = Math.round(phaseInt === 1 ? tdee - 500 : phaseInt === 2 ? tdee - 200 : tdee); + const dailyTarget = macrosForPhase({ kcalTarget, weightKg: weightCurrentKg, phase: phaseInt }); + const exclusions = profile.user.preferences?.exclusions ?? []; + + const weekStart = startOfWeek(new Date()); + + const plan = await fastify.prisma.mealPlan.upsert({ + where: { userId_weekStart: { userId, weekStart } }, + create: { userId, weekStart }, + update: { generatedAt: new Date(), status: 'ACTIVE' }, + }); + + // Wipe existing slots before regenerating, in case the plan already existed. + await fastify.prisma.mealSlot.deleteMany({ where: { planId: plan.id } }); + + const plannedSlots: Array<{ day: number; meal: string; recipeId: string | null }> = []; + for (let day = 0; day < 7; day++) { + // Per ogni pasto, le ricette già scelte negli ultimi 2 giorni sono "recenti". + const recentlyConsumedIds: string[] = plannedSlots + .filter((s) => day - s.day <= 2 && s.recipeId) + .map((s) => s.recipeId!); + + for (const meal of MEALS) { + const top = matchMeals({ + candidates, + meal, + phase: phaseInt, + excludedTags: exclusions, + recentlyConsumedIds, + dailyTarget, + mealShare: DEFAULT_MEAL_SHARE, + topN: 5, + }); + const selected = top[0]; + await fastify.prisma.mealSlot.create({ + data: { + planId: plan.id, + dayOfWeek: day, + meal, + recipeId: selected?.id ?? null, + alternatives: { connect: top.slice(1).map((r) => ({ id: r.id })) }, + }, + }); + plannedSlots.push({ day, meal, recipeId: selected?.id ?? null }); + } + } + + return reply.code(201).send({ + plan: { + id: plan.id, + weekStart: plan.weekStart.toISOString().slice(0, 10), + dailyTarget, + }, + }); + }); + + fastify.get('/me/meal-plans/current', { preHandler: requireAuth() }, async (request, reply) => { + const plan = await fastify.prisma.mealPlan.findFirst({ + where: { userId: request.user!.id, status: 'ACTIVE' }, + orderBy: { weekStart: 'desc' }, + include: { + slots: { + include: { + selected: { select: { id: true, name: true, kcal: true } }, + alternatives: { select: { id: true, name: true, kcal: true } }, + }, + orderBy: [{ dayOfWeek: 'asc' }, { meal: 'asc' }], + }, + }, + }); + if (!plan) return reply.code(404).send({ error: 'plan_not_found' }); + return { + plan: { + id: plan.id, + weekStart: plan.weekStart.toISOString().slice(0, 10), + slots: plan.slots, + }, + }; + }); +}; diff --git a/apps/api/src/modules/tracking/fast.routes.ts b/apps/api/src/modules/tracking/fast.routes.ts new file mode 100644 index 0000000..bef91bd --- /dev/null +++ b/apps/api/src/modules/tracking/fast.routes.ts @@ -0,0 +1,73 @@ +import { + PROTOCOL_DEFAULT_MINUTES, + fastEventStartSchema, + fastEventUpdateSchema, +} from '@ketopath/shared'; +import type { FastifyPluginAsync } from 'fastify'; + +import { requireAuth } from '../../plugins/auth.js'; + +export const fastRoutes: FastifyPluginAsync = async (fastify) => { + fastify.get('/me/fast-events', { preHandler: requireAuth() }, async (request) => { + const events = await fastify.prisma.fastEvent.findMany({ + where: { userId: request.user!.id }, + orderBy: { startedAt: 'desc' }, + take: 30, + }); + return { + events: events.map((e) => ({ + id: e.id, + protocol: e.protocol, + status: e.status, + startedAt: e.startedAt.toISOString(), + endedAt: e.endedAt?.toISOString() ?? null, + targetDuration: e.targetDuration, + symptoms: e.symptoms ? (JSON.parse(e.symptoms) as unknown) : null, + notes: e.notes, + })), + }; + }); + + fastify.post('/me/fast-events', { preHandler: requireAuth() }, async (request, reply) => { + const parsed = fastEventStartSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues }); + } + const data = parsed.data; + const event = await fastify.prisma.fastEvent.create({ + data: { + userId: request.user!.id, + protocol: data.protocol, + startedAt: data.startedAt ?? new Date(), + targetDuration: data.targetDuration ?? PROTOCOL_DEFAULT_MINUTES[data.protocol], + }, + }); + return reply.code(201).send({ event: { id: event.id } }); + }); + + fastify.patch('/me/fast-events/:id', { preHandler: requireAuth() }, async (request, reply) => { + const id = (request.params as { id: string }).id; + const parsed = fastEventUpdateSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues }); + } + const data = parsed.data; + // Garantisce che l'utente possa aggiornare solo i propri eventi. + const owned = await fastify.prisma.fastEvent.findFirst({ + where: { id, userId: request.user!.id }, + select: { id: true }, + }); + if (!owned) return reply.code(404).send({ error: 'fast_event_not_found' }); + + const event = await fastify.prisma.fastEvent.update({ + where: { id }, + data: { + endedAt: data.endedAt ?? undefined, + status: data.status ?? undefined, + symptoms: data.symptoms ? JSON.stringify(data.symptoms) : undefined, + notes: data.notes ?? undefined, + }, + }); + return { event: { id: event.id, status: event.status } }; + }); +}; diff --git a/apps/api/src/modules/tracking/weight.routes.ts b/apps/api/src/modules/tracking/weight.routes.ts new file mode 100644 index 0000000..2024a9f --- /dev/null +++ b/apps/api/src/modules/tracking/weight.routes.ts @@ -0,0 +1,64 @@ +import { weightEntryInputSchema } from '@ketopath/shared'; +import type { FastifyPluginAsync } from 'fastify'; + +import { requireAuth } from '../../plugins/auth.js'; + +export const weightRoutes: FastifyPluginAsync = async (fastify) => { + fastify.get('/me/weight-entries', { preHandler: requireAuth() }, async (request) => { + const entries = await fastify.prisma.weightEntry.findMany({ + where: { userId: request.user!.id }, + orderBy: { date: 'desc' }, + take: 60, + }); + return { + entries: entries.map((e) => ({ + id: e.id, + date: e.date.toISOString().slice(0, 10), + weightKg: Number(e.weightKg), + measurements: e.measurements ? (JSON.parse(e.measurements) as unknown) : null, + notes: e.notes, + energy: e.energy, + sleep: e.sleep, + hunger: e.hunger, + photos: e.photos, + })), + }; + }); + + fastify.post('/me/weight-entries', { preHandler: requireAuth() }, async (request, reply) => { + const parsed = weightEntryInputSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues }); + } + const data = parsed.data; + const userId = request.user!.id; + + const entry = await fastify.prisma.weightEntry.upsert({ + where: { userId_date: { userId, date: data.date } }, + create: { + userId, + date: data.date, + weightKg: String(data.weightKg), + measurements: data.measurements ? JSON.stringify(data.measurements) : null, + notes: data.notes ?? null, + energy: data.energy ?? null, + sleep: data.sleep ?? null, + hunger: data.hunger ?? null, + photos: data.photos ?? [], + }, + update: { + weightKg: String(data.weightKg), + measurements: data.measurements ? JSON.stringify(data.measurements) : null, + notes: data.notes ?? null, + energy: data.energy ?? null, + sleep: data.sleep ?? null, + hunger: data.hunger ?? null, + photos: data.photos ?? [], + }, + }); + + return reply + .code(201) + .send({ entry: { id: entry.id, date: entry.date.toISOString().slice(0, 10) } }); + }); +}; diff --git a/packages/db/prisma/seed-recipes.ts b/packages/db/prisma/seed-recipes.ts new file mode 100644 index 0000000..c259e58 --- /dev/null +++ b/packages/db/prisma/seed-recipes.ts @@ -0,0 +1,218 @@ +// Seed minimo del catalogo ricette italiane keto/low-carb (PRD §5.1). +// 16 ricette distribuite su COLAZIONE/PRANZO/SPUNTINO/CENA, fasi 1/2/3. +// Macros stimati per porzione singola. + +import type { Difficulty, MealCategory } from '@prisma/client'; + +export interface SeedRecipe { + name: string; + category: MealCategory; + description: string; + prepMinutes: number; + difficulty: Difficulty; + kcal: number; + proteinG: number; + fatG: number; + netCarbG: number; + phases: number[]; + notesChef?: string; +} + +export const SEED_RECIPES: SeedRecipe[] = [ + // COLAZIONE + { + name: 'Frittata di spinaci e parmigiano', + category: 'COLAZIONE', + description: 'Tre uova con spinaci saltati e parmigiano grattugiato.', + prepMinutes: 10, + difficulty: 'FACILE', + kcal: 360, + proteinG: 28, + fatG: 26, + netCarbG: 4, + phases: [1, 2, 3], + }, + { + name: 'Yogurt greco con noci e cannella', + category: 'COLAZIONE', + description: 'Yogurt greco intero con un cucchiaio di noci tritate.', + prepMinutes: 3, + difficulty: 'FACILE', + kcal: 320, + proteinG: 18, + fatG: 22, + netCarbG: 8, + phases: [1, 2, 3], + }, + { + name: 'Avocado toast keto su pane di mandorle', + category: 'COLAZIONE', + description: 'Mezzo avocado schiacciato su una fetta di pane proteico.', + prepMinutes: 5, + difficulty: 'FACILE', + kcal: 380, + proteinG: 12, + fatG: 30, + netCarbG: 6, + phases: [1, 2, 3], + }, + { + name: 'Uova strapazzate con pancetta', + category: 'COLAZIONE', + description: 'Due uova strapazzate al burro con pancetta croccante.', + prepMinutes: 8, + difficulty: 'FACILE', + kcal: 410, + proteinG: 24, + fatG: 33, + netCarbG: 2, + phases: [1, 2, 3], + }, + // PRANZO + { + name: 'Insalata di pollo, avocado e pomodorini', + category: 'PRANZO', + description: 'Petto di pollo grigliato, avocado, pomodorini, olio EVO.', + prepMinutes: 15, + difficulty: 'FACILE', + kcal: 540, + proteinG: 38, + fatG: 36, + netCarbG: 9, + phases: [1, 2, 3], + }, + { + name: 'Salmone al forno con asparagi', + category: 'PRANZO', + description: 'Filetto di salmone con asparagi al forno e limone.', + prepMinutes: 20, + difficulty: 'FACILE', + kcal: 510, + proteinG: 36, + fatG: 35, + netCarbG: 6, + phases: [1, 2, 3], + }, + { + name: 'Bistecca con zucchine grigliate', + category: 'PRANZO', + description: 'Tagliata di manzo con zucchine grigliate e olio EVO.', + prepMinutes: 18, + difficulty: 'MEDIA', + kcal: 580, + proteinG: 42, + fatG: 38, + netCarbG: 5, + phases: [1, 2, 3], + }, + { + name: 'Insalatona con tonno e olive', + category: 'PRANZO', + description: "Insalata mista, tonno sott'olio, olive nere, capperi.", + prepMinutes: 10, + difficulty: 'FACILE', + kcal: 470, + proteinG: 32, + fatG: 32, + netCarbG: 7, + phases: [1, 2, 3], + }, + // SPUNTINO + { + name: 'Mandorle al naturale', + category: 'SPUNTINO', + description: 'Una manciata di mandorle (~25 g).', + prepMinutes: 1, + difficulty: 'FACILE', + kcal: 150, + proteinG: 5, + fatG: 13, + netCarbG: 3, + phases: [1, 2, 3], + }, + { + name: 'Cubetti di parmigiano', + category: 'SPUNTINO', + description: '30 g di parmigiano stagionato 24 mesi.', + prepMinutes: 1, + difficulty: 'FACILE', + kcal: 120, + proteinG: 11, + fatG: 8, + netCarbG: 0, + phases: [1, 2, 3], + }, + { + name: 'Olive verdi marinate', + category: 'SPUNTINO', + description: 'Una porzione di olive verdi (60 g) con erbe aromatiche.', + prepMinutes: 1, + difficulty: 'FACILE', + kcal: 90, + proteinG: 1, + fatG: 9, + netCarbG: 1, + phases: [1, 2, 3], + }, + { + name: 'Cetrioli con hummus di zucchine', + category: 'SPUNTINO', + description: 'Cetrioli a bastoncino con hummus light di zucchine.', + prepMinutes: 5, + difficulty: 'FACILE', + kcal: 110, + proteinG: 3, + fatG: 8, + netCarbG: 5, + phases: [2, 3], + }, + // CENA + { + name: 'Branzino al cartoccio con verdure', + category: 'CENA', + description: 'Branzino al cartoccio con zucchine, carote e pomodorini.', + prepMinutes: 25, + difficulty: 'MEDIA', + kcal: 460, + proteinG: 38, + fatG: 28, + netCarbG: 7, + phases: [1, 2, 3], + }, + { + name: 'Polpette di pollo e ricotta', + category: 'CENA', + description: 'Polpette al forno con ricotta, basilico e parmigiano.', + prepMinutes: 30, + difficulty: 'MEDIA', + kcal: 490, + proteinG: 40, + fatG: 32, + netCarbG: 4, + phases: [1, 2, 3], + }, + { + name: 'Zuppa di cavolfiore e gorgonzola', + category: 'CENA', + description: 'Crema di cavolfiore con gorgonzola dolce DOP.', + prepMinutes: 25, + difficulty: 'FACILE', + kcal: 420, + proteinG: 18, + fatG: 33, + netCarbG: 9, + phases: [1, 2, 3], + }, + { + name: 'Tagliata di manzo con rucola e grana', + category: 'CENA', + description: 'Tagliata di manzo su letto di rucola con scaglie di grana.', + prepMinutes: 15, + difficulty: 'MEDIA', + kcal: 540, + proteinG: 44, + fatG: 36, + netCarbG: 3, + phases: [1, 2, 3], + }, +]; diff --git a/packages/db/prisma/seed.ts b/packages/db/prisma/seed.ts index 46e0e8d..319cab0 100644 --- a/packages/db/prisma/seed.ts +++ b/packages/db/prisma/seed.ts @@ -1,12 +1,21 @@ import { PrismaClient } from '@prisma/client'; +import { SEED_RECIPES } from './seed-recipes.js'; + const prisma = new PrismaClient(); async function main(): Promise { - // TODO: dati di seed (utenti demo, ricette base, ecc.) — verranno aggiunti - // quando le entità relative saranno modellate. + // Idempotente: createMany con skipDuplicates su nome (unique implicit nope — + // usiamo upsert per ognuna). + let inserted = 0; + for (const r of SEED_RECIPES) { + const existing = await prisma.recipe.findFirst({ where: { name: r.name } }); + if (existing) continue; + await prisma.recipe.create({ data: r }); + inserted++; + } // eslint-disable-next-line no-console - console.info('[seed] nothing to seed yet'); + console.info(`[seed] recipes inserted: ${inserted} (catalog total ${SEED_RECIPES.length})`); } main() diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 3ad18de..3918470 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,4 +1,8 @@ export * from './nutrition/bmr.js'; export * from './nutrition/tdee.js'; export * from './nutrition/types.js'; +export * from './planner/macros.js'; +export * from './planner/matchmaking.js'; +export * from './planner/types.js'; export * from './profile/schema.js'; +export * from './tracking/schema.js'; diff --git a/packages/shared/src/planner/macros.ts b/packages/shared/src/planner/macros.ts new file mode 100644 index 0000000..7cb1e58 --- /dev/null +++ b/packages/shared/src/planner/macros.ts @@ -0,0 +1,36 @@ +import { type Phase } from './types.js'; + +// PRD §9.3 — distribuzione macro per fase. +// Ritorna i target giornalieri (kcal/proteine/grassi/carboidrati netti) date +// le calorie totali del giorno e la fase. +export interface DailyMacros { + kcal: number; + proteinG: number; + fatG: number; + netCarbG: number; +} + +export interface PhaseMacroInput { + kcalTarget: number; + weightKg: number; + phase: Phase; +} + +const KCAL_PER_GRAM = { protein: 4, fat: 9, netCarb: 4 } as const; + +export function macrosForPhase({ kcalTarget, weightKg, phase }: PhaseMacroInput): DailyMacros { + // Proteine: 1.6 g/kg di peso ideale, leggermente più alte in fase 1. + const proteinPerKg = phase === 1 ? 1.8 : 1.6; + const proteinG = Math.round(weightKg * proteinPerKg); + + // Carboidrati netti per fase (PRD §9.3). + const netCarbG = phase === 1 ? 25 : phase === 2 ? 60 : 120; + + // Grassi: il resto. + const proteinKcal = proteinG * KCAL_PER_GRAM.protein; + const carbKcal = netCarbG * KCAL_PER_GRAM.netCarb; + const fatKcal = Math.max(0, kcalTarget - proteinKcal - carbKcal); + const fatG = Math.round(fatKcal / KCAL_PER_GRAM.fat); + + return { kcal: kcalTarget, proteinG, fatG, netCarbG }; +} diff --git a/packages/shared/src/planner/matchmaking.test.ts b/packages/shared/src/planner/matchmaking.test.ts new file mode 100644 index 0000000..b7d0e53 --- /dev/null +++ b/packages/shared/src/planner/matchmaking.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; + +import { DEFAULT_MEAL_SHARE, matchMeals, type RecipeCandidate } from './matchmaking.js'; + +const recipes: RecipeCandidate[] = [ + { + id: 'r1', + name: 'Frittata di spinaci', + category: 'COLAZIONE', + kcal: 350, + proteinG: 25, + fatG: 25, + netCarbG: 4, + exclusionTags: ['eggs', 'lactose'], + phases: [1, 2, 3], + }, + { + id: 'r2', + name: 'Avocado toast keto', + category: 'COLAZIONE', + kcal: 380, + proteinG: 12, + fatG: 30, + netCarbG: 6, + exclusionTags: [], + phases: [1, 2, 3], + }, + { + id: 'r3', + name: 'Pasta integrale al pomodoro', + category: 'COLAZIONE', + kcal: 420, + proteinG: 14, + fatG: 8, + netCarbG: 60, + exclusionTags: ['gluten'], + phases: [3], // ammessa solo in mantenimento + }, +]; + +describe('matchMeals', () => { + it('filters by excluded tags', () => { + const out = matchMeals({ + candidates: recipes, + meal: 'COLAZIONE', + phase: 1, + excludedTags: ['eggs'], + dailyTarget: { kcal: 1900, proteinG: 130, fatG: 130, netCarbG: 25 }, + mealShare: DEFAULT_MEAL_SHARE, + }); + expect(out.find((r) => r.id === 'r1')).toBeUndefined(); + expect(out.find((r) => r.id === 'r2')).toBeDefined(); + }); + + it('filters by phase compatibility', () => { + const out = matchMeals({ + candidates: recipes, + meal: 'COLAZIONE', + phase: 1, + excludedTags: [], + dailyTarget: { kcal: 1900, proteinG: 130, fatG: 130, netCarbG: 25 }, + mealShare: DEFAULT_MEAL_SHARE, + }); + expect(out.find((r) => r.id === 'r3')).toBeUndefined(); // r3 è solo fase 3 + }); + + it('penalises recently consumed recipes', () => { + const without = matchMeals({ + candidates: recipes, + meal: 'COLAZIONE', + phase: 1, + excludedTags: [], + dailyTarget: { kcal: 1900, proteinG: 130, fatG: 130, netCarbG: 25 }, + mealShare: DEFAULT_MEAL_SHARE, + }); + const winner = without[0]!.id; + const withRecent = matchMeals({ + candidates: recipes, + meal: 'COLAZIONE', + phase: 1, + excludedTags: [], + recentlyConsumedIds: [winner], + dailyTarget: { kcal: 1900, proteinG: 130, fatG: 130, netCarbG: 25 }, + mealShare: DEFAULT_MEAL_SHARE, + }); + expect(withRecent[0]!.id).not.toBe(winner); + }); + + it('respects topN', () => { + const out = matchMeals({ + candidates: recipes, + meal: 'COLAZIONE', + phase: 1, + excludedTags: [], + dailyTarget: { kcal: 1900, proteinG: 130, fatG: 130, netCarbG: 25 }, + mealShare: DEFAULT_MEAL_SHARE, + topN: 1, + }); + expect(out).toHaveLength(1); + }); + + it('ignores recipes whose category does not match the meal', () => { + const out = matchMeals({ + candidates: recipes, + meal: 'CENA', + phase: 1, + excludedTags: [], + dailyTarget: { kcal: 1900, proteinG: 130, fatG: 130, netCarbG: 25 }, + mealShare: DEFAULT_MEAL_SHARE, + }); + expect(out).toHaveLength(0); + }); +}); diff --git a/packages/shared/src/planner/matchmaking.ts b/packages/shared/src/planner/matchmaking.ts new file mode 100644 index 0000000..f9c9b43 --- /dev/null +++ b/packages/shared/src/planner/matchmaking.ts @@ -0,0 +1,100 @@ +// PRD §9.4 — algoritmo di matchmaking pasti. +// Implementazione pura senza dipendenze: filtra ricette per esclusioni, fase +// e categoria pasto; calcola un punteggio di distanza dai macros target; +// penalizza ricette consumate di recente; bilancia sui macros già accumulati. +// Non importa nulla da @ketopath/db perché l'algoritmo deve essere +// utilizzabile anche con fixture in test e con feature-flag indipendenti +// dalla persistenza. + +export interface RecipeCandidate { + id: string; + name: string; + category: 'COLAZIONE' | 'PRANZO' | 'SPUNTINO' | 'CENA'; + kcal: number; + proteinG: number; + fatG: number; + netCarbG: number; + exclusionTags: ReadonlyArray; + phases: ReadonlyArray<1 | 2 | 3>; +} + +export interface MacroTargets { + kcal: number; + proteinG: number; + fatG: number; + netCarbG: number; +} + +export interface MatchOptions { + candidates: ReadonlyArray; + meal: RecipeCandidate['category']; + phase: 1 | 2 | 3; + excludedTags: ReadonlyArray; + recentlyConsumedIds?: ReadonlyArray; + // Macros già consumati nello stesso giorno; usati per bilanciare le scelte + // successive (la cena tiene conto di colazione/pranzo/spuntino). + consumedSoFar?: MacroTargets; + dailyTarget: MacroTargets; + // Quote del pasto sul totale giornaliero (sommano a 1.0). + mealShare: { COLAZIONE: number; PRANZO: number; SPUNTINO: number; CENA: number }; + topN?: number; // default 5 +} + +export interface MatchResult extends RecipeCandidate { + score: number; +} + +/** + * Restituisce le `topN` ricette più adatte, ordinate dalla migliore + * (score più basso) alla peggiore. Lo score combina: + * - distanza euclidea dai macros target del pasto (pesato sul giorno residuo) + * - penalità additiva se la ricetta è stata consumata negli ultimi N giorni + */ +export function matchMeals(opts: MatchOptions): MatchResult[] { + const consumed = opts.consumedSoFar ?? { kcal: 0, proteinG: 0, fatG: 0, netCarbG: 0 }; + const remainingTarget: MacroTargets = { + kcal: Math.max(0, opts.dailyTarget.kcal - consumed.kcal), + proteinG: Math.max(0, opts.dailyTarget.proteinG - consumed.proteinG), + fatG: Math.max(0, opts.dailyTarget.fatG - consumed.fatG), + netCarbG: Math.max(0, opts.dailyTarget.netCarbG - consumed.netCarbG), + }; + const share = opts.mealShare[opts.meal]; + const mealTarget: MacroTargets = { + kcal: remainingTarget.kcal * share, + proteinG: remainingTarget.proteinG * share, + fatG: remainingTarget.fatG * share, + netCarbG: remainingTarget.netCarbG * share, + }; + + const recentSet = new Set(opts.recentlyConsumedIds ?? []); + const exclusionSet = new Set(opts.excludedTags); + + const results: MatchResult[] = []; + + for (const r of opts.candidates) { + if (r.category !== opts.meal) continue; + if (!r.phases.includes(opts.phase)) continue; + if (r.exclusionTags.some((tag) => exclusionSet.has(tag))) continue; + + const dKcal = (r.kcal - mealTarget.kcal) / Math.max(1, mealTarget.kcal); + const dPro = (r.proteinG - mealTarget.proteinG) / Math.max(1, mealTarget.proteinG); + const dFat = (r.fatG - mealTarget.fatG) / Math.max(1, mealTarget.fatG); + const dCarb = (r.netCarbG - mealTarget.netCarbG) / Math.max(1, mealTarget.netCarbG); + + let score = Math.sqrt(dKcal * dKcal + dPro * dPro + dFat * dFat + dCarb * dCarb); + if (recentSet.has(r.id)) score += 1.5; + + results.push({ ...r, score }); + } + + results.sort((a, b) => a.score - b.score); + return results.slice(0, opts.topN ?? 5); +} + +// Default share per pasto (somma 1.0). Può essere sovrascritta dall'utente. +export const DEFAULT_MEAL_SHARE = { + COLAZIONE: 0.25, + PRANZO: 0.35, + SPUNTINO: 0.1, + CENA: 0.3, +} as const; diff --git a/packages/shared/src/planner/types.ts b/packages/shared/src/planner/types.ts new file mode 100644 index 0000000..d35f755 --- /dev/null +++ b/packages/shared/src/planner/types.ts @@ -0,0 +1 @@ +export type Phase = 1 | 2 | 3; diff --git a/packages/shared/src/tracking/schema.ts b/packages/shared/src/tracking/schema.ts new file mode 100644 index 0000000..8799271 --- /dev/null +++ b/packages/shared/src/tracking/schema.ts @@ -0,0 +1,70 @@ +import { z } from 'zod'; + +export const FASTING_PROTOCOLS = [ + 'FOURTEEN_TEN', + 'SIXTEEN_EIGHT', + 'EIGHTEEN_SIX', + 'TWENTY_FOUR', + 'ESE_24', + 'FIVE_TWO', +] as const; + +export const FAST_STATUSES = ['IN_PROGRESS', 'COMPLETED', 'ABORTED'] as const; + +const measurementsSchema = z + .object({ + waistCm: z.coerce.number().min(40).max(200).optional(), + hipsCm: z.coerce.number().min(40).max(200).optional(), + thighCm: z.coerce.number().min(20).max(120).optional(), + armCm: z.coerce.number().min(15).max(80).optional(), + }) + .strict(); + +export const weightEntryInputSchema = z.object({ + date: z.coerce.date(), + weightKg: z.coerce.number().min(35).max(300), + measurements: measurementsSchema.optional(), + notes: z.string().max(1000).optional(), + energy: z.coerce.number().int().min(1).max(10).optional(), + sleep: z.coerce.number().int().min(1).max(10).optional(), + hunger: z.coerce.number().int().min(1).max(10).optional(), + photos: z.array(z.string().url()).max(3).optional(), +}); + +export type WeightEntryInput = z.input; + +const symptomsSchema = z + .object({ + headache: z.boolean().optional(), + energy: z.coerce.number().int().min(1).max(10).optional(), + hunger: z.coerce.number().int().min(1).max(10).optional(), + clarity: z.coerce.number().int().min(1).max(10).optional(), + other: z.string().max(500).optional(), + }) + .strict(); + +export const fastEventStartSchema = z.object({ + protocol: z.enum(FASTING_PROTOCOLS), + startedAt: z.coerce.date().optional(), + targetDuration: z.coerce.number().int().positive().optional(), +}); + +export const fastEventUpdateSchema = z.object({ + endedAt: z.coerce.date().optional(), + status: z.enum(FAST_STATUSES).optional(), + symptoms: symptomsSchema.optional(), + notes: z.string().max(1000).optional(), +}); + +export type FastEventStartInput = z.input; +export type FastEventUpdateInput = z.input; + +// Default targetDuration in minutes per protocol. +export const PROTOCOL_DEFAULT_MINUTES: Record<(typeof FASTING_PROTOCOLS)[number], number> = { + FOURTEEN_TEN: 14 * 60, + SIXTEEN_EIGHT: 16 * 60, + EIGHTEEN_SIX: 18 * 60, + TWENTY_FOUR: 20 * 60, + ESE_24: 24 * 60, + FIVE_TWO: 24 * 60, +};