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,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;
|
||||
Reference in new issue
Block a user