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:
lucianoandClaude Opus 4.7 committed 2026-04-29 16:07:56 +02:00
1 parent 0d2d1f49c7
commit 4b6c3f6ae9
12 files changed
+854 -3

No files matched your search

+4
View File
@@ -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';
+36
View File
@@ -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 };
}
@@ -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);
});
});
+100
View File
@@ -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<string>;
phases: ReadonlyArray<1 | 2 | 3>;
}
export interface MacroTargets {
kcal: number;
proteinG: number;
fatG: number;
netCarbG: number;
}
export interface MatchOptions {
candidates: ReadonlyArray<RecipeCandidate>;
meal: RecipeCandidate['category'];
phase: 1 | 2 | 3;
excludedTags: ReadonlyArray<string>;
recentlyConsumedIds?: ReadonlyArray<string>;
// 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;
+1
View File
@@ -0,0 +1 @@
export type Phase = 1 | 2 | 3;
+70
View File
@@ -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<typeof weightEntryInputSchema>;
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<typeof fastEventStartSchema>;
export type FastEventUpdateInput = z.input<typeof fastEventUpdateSchema>;
// 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,
};