feat: composizione corporea, deficit dinamico, condizioni mediche (PRD §5.1)
Tre leve per migliorare l'accuratezza del piano oltre Mifflin-St Jeor.
1. Composizione corporea (US Navy + Katch-McArdle)
- Profile: neckCm/waistCm/hipsCm opzionali in input
- bodyFatPct calcolato lato server con la formula US Navy metrica
(Hodgdon-Beckett 1984) e cifrato at-rest come dato sanitario
- BMR usa Katch-McArdle (FFM-based) quando bodyFatPct è noto, Mifflin
altrimenti — più accurato del 5-10% sui casi fuori-norma
- Test: estimateBodyFatPercentageUSNavy + calculateBmrKatchMcArdle
2. Deficit dinamico
- Profile.targetWeeklyLossKg (kg/settimana, opzionale)
- computeDailyKcalTarget calcola il deficit da kg/sett ×7700/7 con due cap
di sicurezza: 30% TDEE (Schoenfeld 2014) e 1% peso/sett (Helms 2014)
- plan.routes lo usa al posto del -500/-200 hard-coded
- Test: 6 casi (mantenimento, transizione, intensiva, cap peso, cap TDEE,
default mancante)
3. Condizioni mediche strutturate
- Profile.medicalConditions (array JSON cifrato)
- 11 condizioni: 7 adattabili (tiroide → BMR -10%, diabete II, IBS,
dislipidemia, ipertensione, reni → cap proteine 0.8 g/kg, fegato →
1.0 g/kg) + 4 escludenti (gravidanza, allattamento, diabete I, disturbi
alimentari)
- PATCH /me/profile/conditions per salvataggio mirato dall'onboarding
- hasExcludingCondition: plan.routes restituisce 409 medical_block se
attiva una condizione incompatibile
Onboarding allargato a 6 step: aggiunto "Condizioni mediche" tra Profile
e Preferences. Step labels e numeri rinumerati.
Schema migration 20260429203451_add_profile_health_fields.
44/44 unit test verdi.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
feb54c1f9a
commit
9401497f03
15 files changed
+744
-26
No files matched your search
@@ -177,10 +177,32 @@
|
||||
"steps": {
|
||||
"goal": "L'obiettivo",
|
||||
"profile": "Anagrafica e attività",
|
||||
"conditions": "Condizioni mediche",
|
||||
"preferences": "Esclusioni e cucine",
|
||||
"notifications": "Promemoria",
|
||||
"done": "Pronti a partire"
|
||||
},
|
||||
"conditionsHeading": "Qualcosa di te che dovremmo sapere?",
|
||||
"conditionsHint": "Indica le condizioni che ti riguardano. Servono ad adattare i calcoli e a segnalarti i casi in cui meglio consultare un medico prima di proseguire.",
|
||||
"conditionsAdaptive": "Condizioni adattabili",
|
||||
"conditionsExcluding": "Condizioni che richiedono il medico",
|
||||
"conditionsExcludingHint": "Se selezioni una di queste l'app non genera piani autonomamente: vai dal tuo medico.",
|
||||
"conditionsSaveError": "Non è stato possibile salvare. Riprova fra poco.",
|
||||
"blockedEyebrow": "Importante",
|
||||
"blockedMessage": "KetoPath non è uno strumento adatto a questa condizione. Ti consigliamo di rivolgerti al tuo medico curante prima di continuare. Puoi comunque proseguire l'esplorazione, ma il piano non verrà generato automaticamente.",
|
||||
"conditionLabel": {
|
||||
"THYROID_HYPO": "Ipotiroidismo",
|
||||
"DIABETES_T2": "Diabete tipo 2",
|
||||
"IBS": "Intestino irritabile",
|
||||
"DYSLIPIDEMIA": "Colesterolo / trigliceridi alti",
|
||||
"HYPERTENSION": "Ipertensione",
|
||||
"KIDNEY_ISSUES": "Problemi renali",
|
||||
"LIVER_ISSUES": "Problemi epatici",
|
||||
"PREGNANCY": "Gravidanza",
|
||||
"BREASTFEEDING": "Allattamento",
|
||||
"DIABETES_T1": "Diabete tipo 1",
|
||||
"EATING_DISORDER": "Disturbi alimentari"
|
||||
},
|
||||
"goalHeading": "Perché sei qui?",
|
||||
"goalHint": "Scegli il motivo principale: ci aiuterà a parlarti nel modo giusto. Potrai sempre cambiarlo.",
|
||||
"goals": {
|
||||
@@ -430,6 +452,13 @@
|
||||
"weightCurrentKg": "Attuale",
|
||||
"weightGoalKg": "Obiettivo",
|
||||
"activityLevel": "Livello di attività",
|
||||
"measurementsTitle": "Circonferenze (cm) — opzionali",
|
||||
"measurementsHint": "Se inserisci collo, vita e (per le donne) fianchi, calcoliamo la tua composizione corporea con la formula US Navy e usiamo Katch-McArdle invece di Mifflin: BMR più accurato.",
|
||||
"neckCm": "Collo",
|
||||
"waistCm": "Vita",
|
||||
"hipsCm": "Fianchi",
|
||||
"targetWeeklyLossKg": "Velocità di calo (kg/settimana)",
|
||||
"targetWeeklyLossKgHint": "Suggerito: 0.4–0.7 kg/settimana. Oltre 1% del peso corporeo è considerato troppo aggressivo.",
|
||||
"selectPlaceholder": "Seleziona…",
|
||||
"save": "Salva profilo",
|
||||
"saving": "Salvataggio…",
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
EXCLUDING_CONDITIONS,
|
||||
MEDICAL_CONDITIONS,
|
||||
type CookingTimeLevel,
|
||||
type CuisineTag,
|
||||
type ExclusionGroup,
|
||||
type MedicalCondition,
|
||||
type PreferencesPatch,
|
||||
} from '@ketopath/shared';
|
||||
import Link from 'next/link';
|
||||
@@ -20,11 +23,12 @@ import { Button } from '@/components/ui/button';
|
||||
import { pushSupported, subscribe, getCurrentSubscription } from '@/lib/notifications/push-client';
|
||||
|
||||
import { regeneratePlan } from '../plan/actions';
|
||||
import { saveConditions } from '../profile/actions';
|
||||
import { savePreferences, type PreferencesView } from '../profile/preferences-actions';
|
||||
import { ProfileForm as ProfileFormComponent } from '../profile/profile-form';
|
||||
import type { ProfileForm } from '../profile/profile-form';
|
||||
|
||||
const STEPS = ['goal', 'profile', 'preferences', 'notifications', 'done'] as const;
|
||||
const STEPS = ['goal', 'profile', 'conditions', 'preferences', 'notifications', 'done'] as const;
|
||||
type StepKey = (typeof STEPS)[number];
|
||||
|
||||
const GOALS = ['LOSE', 'MAINTAIN', 'ENERGY'] as const;
|
||||
@@ -59,7 +63,7 @@ export function OnboardingFlow({ initialProfile, initialPreferences }: Onboardin
|
||||
i < stepIndex ? 'text-oliva' : i === stepIndex ? 'text-pomodoro' : 'text-ink-dim'
|
||||
}`}
|
||||
>
|
||||
{['I', 'II', 'III', 'IV', 'V'][i]}
|
||||
{['I', 'II', 'III', 'IV', 'V', 'VI'][i]}
|
||||
</span>
|
||||
<span
|
||||
className={`font-display text-lg leading-tight ${
|
||||
@@ -79,10 +83,14 @@ export function OnboardingFlow({ initialProfile, initialPreferences }: Onboardin
|
||||
) : null}
|
||||
|
||||
{step === 'profile' ? (
|
||||
<StepProfile
|
||||
initial={initialProfile}
|
||||
goal={goal}
|
||||
onSaved={() => setStep('preferences')}
|
||||
<StepProfile initial={initialProfile} goal={goal} onSaved={() => setStep('conditions')} />
|
||||
) : null}
|
||||
|
||||
{step === 'conditions' ? (
|
||||
<StepConditions
|
||||
initial={(initialProfile?.medicalConditions as string[] | undefined) ?? []}
|
||||
onContinue={() => setStep('preferences')}
|
||||
onBack={() => setStep('profile')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -92,7 +100,7 @@ export function OnboardingFlow({ initialProfile, initialPreferences }: Onboardin
|
||||
initialPreferences ? toFormValues(initialPreferences) : DEFAULT_PREFERENCES_VALUES
|
||||
}
|
||||
onSaved={() => setStep('notifications')}
|
||||
onBack={() => setStep('profile')}
|
||||
onBack={() => setStep('conditions')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -117,7 +125,7 @@ function StepGoal({
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="space-y-2">
|
||||
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 1, total: 5 })}</p>
|
||||
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 1, total: 6 })}</p>
|
||||
<h2 className="font-display text-ink text-3xl font-medium leading-tight">
|
||||
{t('goalHeading')}
|
||||
</h2>
|
||||
@@ -182,7 +190,7 @@ function StepProfile({
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="space-y-2">
|
||||
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 2, total: 5 })}</p>
|
||||
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 2, total: 6 })}</p>
|
||||
<h2 className="font-display text-ink text-3xl font-medium leading-tight">
|
||||
{goal ? t(`profileHeadingGoal.${goal}`) : t('profileHeading')}
|
||||
</h2>
|
||||
@@ -195,6 +203,145 @@ function StepProfile({
|
||||
);
|
||||
}
|
||||
|
||||
function StepConditions({
|
||||
initial,
|
||||
onContinue,
|
||||
onBack,
|
||||
}: {
|
||||
initial: string[];
|
||||
onContinue: () => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const t = useTranslations('Onboarding');
|
||||
const [selected, setSelected] = useState<string[]>(initial);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function toggle(c: MedicalCondition): void {
|
||||
setSelected((prev) => (prev.includes(c) ? prev.filter((x) => x !== c) : [...prev, c]));
|
||||
}
|
||||
|
||||
const blocked = selected.some((c) => EXCLUDING_CONDITIONS.has(c as MedicalCondition));
|
||||
|
||||
async function handleNext(): Promise<void> {
|
||||
setError(null);
|
||||
setPending(true);
|
||||
const result = await saveConditions(selected);
|
||||
setPending(false);
|
||||
if (!result.ok) {
|
||||
setError(t('conditionsSaveError'));
|
||||
return;
|
||||
}
|
||||
onContinue();
|
||||
}
|
||||
|
||||
const adaptive = MEDICAL_CONDITIONS.filter(
|
||||
(c) => !EXCLUDING_CONDITIONS.has(c as MedicalCondition),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="space-y-2">
|
||||
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 3, total: 6 })}</p>
|
||||
<h2 className="font-display text-ink text-3xl font-medium leading-tight">
|
||||
{t('conditionsHeading')}
|
||||
</h2>
|
||||
<p className="font-display text-ink-soft max-w-xl text-base italic leading-snug">
|
||||
{t('conditionsHint')}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="editorial-eyebrow">{t('conditionsAdaptive')}</legend>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{adaptive.map((c) => (
|
||||
<ConditionChip
|
||||
key={c}
|
||||
label={t(`conditionLabel.${c}`)}
|
||||
active={selected.includes(c)}
|
||||
onToggle={() => toggle(c as MedicalCondition)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="editorial-eyebrow">{t('conditionsExcluding')}</legend>
|
||||
<p className="font-display text-ink-soft text-sm italic leading-snug">
|
||||
{t('conditionsExcludingHint')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Array.from(EXCLUDING_CONDITIONS).map((c) => (
|
||||
<ConditionChip
|
||||
key={c}
|
||||
label={t(`conditionLabel.${c}`)}
|
||||
active={selected.includes(c)}
|
||||
onToggle={() => toggle(c)}
|
||||
danger
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{blocked ? (
|
||||
<div className="border-pomodoro/40 bg-pomodoro/5 border-2 border-dashed p-5">
|
||||
<p className="editorial-eyebrow">{t('blockedEyebrow')}</p>
|
||||
<p className="font-display text-ink mt-3 text-base leading-snug">{t('blockedMessage')}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="font-display text-pomodoro text-base italic" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Button type="button" size="lg" onClick={handleNext} disabled={pending}>
|
||||
{pending ? t('working') : t('next')}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="text-ink-soft hover:text-ink font-mono text-[11px] uppercase tracking-widest"
|
||||
>
|
||||
{t('back')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConditionChip({
|
||||
label,
|
||||
active,
|
||||
onToggle,
|
||||
danger,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onToggle: () => void;
|
||||
danger?: boolean;
|
||||
}) {
|
||||
const palette = active
|
||||
? danger
|
||||
? 'bg-pomodoro text-carta-light border-pomodoro'
|
||||
: 'bg-ink text-carta-light border-ink'
|
||||
: danger
|
||||
? 'border-pomodoro/40 hover:border-pomodoro text-pomodoro hover:bg-pomodoro/5 bg-transparent'
|
||||
: 'border-ink/30 hover:border-ink text-ink hover:bg-ink/5 bg-transparent';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={onToggle}
|
||||
className={`font-display rounded-full border px-4 py-2 text-sm leading-tight transition-colors ${palette}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StepPreferences({
|
||||
initial,
|
||||
onSaved,
|
||||
@@ -215,7 +362,7 @@ function StepPreferences({
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="space-y-2">
|
||||
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 3, total: 5 })}</p>
|
||||
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 4, total: 6 })}</p>
|
||||
<h2 className="font-display text-ink text-3xl font-medium leading-tight">
|
||||
{t('preferencesHeading')}
|
||||
</h2>
|
||||
@@ -265,7 +412,7 @@ function StepNotifications({ onContinue }: { onContinue: () => void }) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="space-y-2">
|
||||
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 4, total: 5 })}</p>
|
||||
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 5, total: 6 })}</p>
|
||||
<h2 className="font-display text-ink text-3xl font-medium leading-tight">
|
||||
{t('notificationsHeading')}
|
||||
</h2>
|
||||
@@ -339,7 +486,7 @@ function StepDone({ goal }: { goal: Goal | null }) {
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<header className="space-y-2">
|
||||
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 5, total: 5 })}</p>
|
||||
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 6, total: 6 })}</p>
|
||||
<h2 className="font-display text-ink text-4xl font-medium leading-tight">
|
||||
{goal ? t(`doneHeadingGoal.${goal}`) : t('doneHeading')}
|
||||
</h2>
|
||||
|
||||
@@ -41,3 +41,19 @@ export async function fetchProfile(): Promise<unknown | null> {
|
||||
const data = (await res.json()) as { profile: unknown };
|
||||
return data.profile;
|
||||
}
|
||||
|
||||
export type SaveConditionsResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
export async function saveConditions(conditions: readonly string[]): Promise<SaveConditionsResult> {
|
||||
const cookie = headers().get('cookie') ?? '';
|
||||
const res = await fetch(`${API_URL}/me/profile/conditions`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', cookie },
|
||||
body: JSON.stringify({ conditions }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: `api_error_${res.status}` };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -190,6 +190,65 @@ export function ProfileForm({
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<p className="editorial-eyebrow">{t('measurementsTitle')}</p>
|
||||
<p className="font-display text-ink-soft text-sm italic leading-snug">
|
||||
{t('measurementsHint')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-x-6 gap-y-8">
|
||||
{(['neckCm', 'waistCm', 'hipsCm'] as const).map((name) => (
|
||||
<FormField
|
||||
key={name}
|
||||
control={form.control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3">
|
||||
<FormLabel>{t(name)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.5"
|
||||
inputMode="decimal"
|
||||
{...field}
|
||||
value={(field.value as number | undefined) ?? ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="targetWeeklyLossKg"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3">
|
||||
<FormLabel>{t('targetWeeklyLossKg')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min={0.1}
|
||||
max={1.5}
|
||||
inputMode="decimal"
|
||||
placeholder="0.5"
|
||||
{...field}
|
||||
value={(field.value as number | undefined) ?? ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<p className="font-display text-ink-soft text-sm italic leading-snug">
|
||||
{t('targetWeeklyLossKgHint')}
|
||||
</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{serverError ? (
|
||||
<p role="alert" className="font-display text-pomodoro text-base italic">
|
||||
{serverError}
|
||||
|
||||
Reference in new issue
Block a user