feat: preferences, exclusion-aware planner, /onboarding (PRD §5.1, §3)

Preferences (PRD §5.1)
- @ketopath/shared: zod schemas + EXCLUSION_GROUPS, CUISINE_TAGS,
  COOKING_TIMES, FASTING_PROTOCOL_VALUES
- apps/api: GET/PATCH /me/preferences (exclusions, cuisinePreferences,
  cookingTime, fastingProtocol)
- apps/web: PreferencesForm chip-based + PreferencesSection wired into
  /profile so existing users can edit
- Plan generation now aggregates Ingredient.exclusionGroups → Recipe
  exclusionTags so matchMeals filters out excluded recipes

Onboarding (PRD §3)
- /onboarding multi-step (profile → preferences → done) with smart-jump:
  starts at the first incomplete step
- ProfileForm gains optional onSaved/saveLabel for reuse outside /profile
- Redirect chain updated: /welcome accept → /onboarding (was /profile);
  /plan, /tracking, /shopping redirect missing profiles to /onboarding
- /profile remains the editing surface for returning users

i18n
- New Onboarding + Preferences namespaces (it)

Smoke tested end-to-end: selected fish exclusion, regenerated the plan,
verified Salmone/Branzino/Tonno are filtered out and unaffected recipes
(Frittata, Pollo, Mandorle, Polpette) remain.

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 22:14:50 +02:00
1 parent cf7361f735
commit 624152b29b
18 files changed
+864 -22

No files matched your search

+1
View File
@@ -1,4 +1,5 @@
export * from './notifications/schema.js';
export * from './preferences/schema.js';
export * from './nutrition/bmr.js';
export * from './nutrition/tdee.js';
export * from './nutrition/types.js';
+51
View File
@@ -0,0 +1,51 @@
import { z } from 'zod';
// PRD §5.1 — gruppi di esclusione legati agli ingredienti.
// Devono coincidere con i tag su `Ingredient.exclusionGroups` nel seed
// (vedi packages/db/prisma/seed-ingredients.ts).
export const EXCLUSION_GROUPS = [
'lactose',
'egg',
'fish',
'nuts',
'gluten',
'pork',
'beef',
] as const;
export type ExclusionGroup = (typeof EXCLUSION_GROUPS)[number];
// Tradizioni regionali italiane + low-carb internazionali.
// Per ora informative: il matchmaking ne tiene conto solo come bonus.
export const CUISINE_TAGS = [
'mediterranea',
'italiana',
'pugliese',
'siciliana',
'toscana',
'medio-orientale',
'nordica',
] as const;
export type CuisineTag = (typeof CUISINE_TAGS)[number];
export const COOKING_TIMES = ['LOW', 'MEDIUM', 'HIGH'] as const;
export type CookingTimeLevel = (typeof COOKING_TIMES)[number];
export const FASTING_PROTOCOL_VALUES = [
'FOURTEEN_TEN',
'SIXTEEN_EIGHT',
'EIGHTEEN_SIX',
'TWENTY_FOUR',
'ESE_24',
'FIVE_TWO',
] as const;
export const preferencesPatchSchema = z
.object({
exclusions: z.array(z.enum(EXCLUSION_GROUPS)).max(EXCLUSION_GROUPS.length).optional(),
cuisinePreferences: z.array(z.enum(CUISINE_TAGS)).max(CUISINE_TAGS.length).optional(),
cookingTime: z.enum(COOKING_TIMES).optional(),
fastingProtocol: z.enum(FASTING_PROTOCOL_VALUES).nullable().optional(),
})
.strict();
export type PreferencesPatch = z.infer<typeof preferencesPatchSchema>;