feat: V1 tracking endpoints + meal plan generation (PRD §5.1, §5.2, §5.3)
@ketopath/shared:
- tracking/schema.ts — Zod schemas for WeightEntry input (weight, optional
measurements/notes/energy/sleep/hunger, photo URLs) and FastEvent start /
update, plus PROTOCOL_DEFAULT_MINUTES table
- planner/macros.ts — macrosForPhase(): protein 1.6-1.8g/kg, netCarb 25/60/120g
by phase, fat = remainder (PRD §9.3)
- planner/matchmaking.ts — pure matchMeals() that filters by exclusion tags,
phase compatibility and meal category, then scores by euclidean distance
from the meal's macro target with a 1.5 penalty for recently-consumed recipes;
DEFAULT_MEAL_SHARE constant (25/35/10/30 %)
- 5 new unit tests cover exclusion, phase, recency, topN, category mismatch
apps/api:
- modules/tracking/weight.routes.ts — GET /me/weight-entries (last 60),
POST /me/weight-entries with upsert on (userId, date); encrypted fields
serialised to/from JSON strings
- modules/tracking/fast.routes.ts — GET, POST (start) and PATCH (update) on
fast events; symptoms stored as encrypted JSON
- modules/plan/plan.routes.ts — POST /me/meal-plans:
- reads profile + preferences, computes BMR (Mifflin-St Jeor), TDEE, daily
kcal target with the phase-dependent deficit, then macros via macrosForPhase
- upserts a MealPlan rooted at Monday-of-this-week, wipes prior slots, and
fills 28 slots running matchMeals per (day, meal) with a recent-2-day
rolling exclusion list to keep variety
- selected recipe + 4 alternatives per slot
- GET /me/meal-plans/current returns the active plan with selected/alternatives
@ketopath/db:
- prisma/seed-recipes.ts — 16 italian keto recipes (4 per meal type) with
estimated macros per serving and phase compatibility
- prisma/seed.ts — idempotent insertion (skip on existing name match)
Verified end-to-end with sign-up → create profile (Michele PRD persona) →
generate plan: 28 slots filled, daily target 1399 kcal / 137 P / 83 F / 25 C,
matchmaking distributes 4 different breakfasts across the first 4 days as
the recent-consumed penalty kicks in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
0d2d1f49c7
commit
4b6c3f6ae9
12 files changed
+854
-3
No files matched your search
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
Reference in new issue
Block a user