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