diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 4c5514a..f779430 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -14,6 +14,7 @@ import { planRoutes } from './modules/plan/plan.routes.js'; import { preferencesRoutes } from './modules/preferences/preferences.routes.js'; import { profileRoutes } from './modules/profile/profile.routes.js'; import { shoppingRoutes } from './modules/shopping/shopping.routes.js'; +import { checkInRoutes } from './modules/tracking/check-in.routes.js'; import { fastRoutes } from './modules/tracking/fast.routes.js'; import { weightRoutes } from './modules/tracking/weight.routes.js'; import { authPlugin } from './plugins/auth.js'; @@ -55,6 +56,7 @@ export async function buildApp(): Promise { await app.register(preferencesRoutes); await app.register(weightRoutes); await app.register(fastRoutes); + await app.register(checkInRoutes); await app.register(planRoutes); await app.register(shoppingRoutes); await app.register(notificationsRoutes); diff --git a/apps/api/src/modules/profile/profile.routes.ts b/apps/api/src/modules/profile/profile.routes.ts index 200ecfe..f16fe39 100644 --- a/apps/api/src/modules/profile/profile.routes.ts +++ b/apps/api/src/modules/profile/profile.routes.ts @@ -28,6 +28,7 @@ function serialize( targetWeeklyLossKg: number | null; medicalConditions: string | null; bodyFatPct: string | null; + alertWeightKg: string | null; dietHistory: string | null; currentPhase: string; } | null, @@ -56,6 +57,7 @@ function serialize( targetWeeklyLossKg: profile.targetWeeklyLossKg, medicalConditions: parseConditions(profile.medicalConditions), bodyFatPct, + alertWeightKg: profile.alertWeightKg ? Number(profile.alertWeightKg) : null, dietHistory: profile.dietHistory, currentPhase: profile.currentPhase, derived: { @@ -130,6 +132,7 @@ export const profileRoutes: FastifyPluginAsync = async (fastify) => { targetWeeklyLossKg: data.targetWeeklyLossKg ?? null, medicalConditions: conditionsJson, dietHistory: data.dietHistory ?? null, + alertWeightKg: data.alertWeightKg != null ? String(data.alertWeightKg) : null, bodyFatPct, }, update: { @@ -144,6 +147,7 @@ export const profileRoutes: FastifyPluginAsync = async (fastify) => { targetWeeklyLossKg: data.targetWeeklyLossKg ?? null, medicalConditions: conditionsJson, dietHistory: data.dietHistory ?? null, + alertWeightKg: data.alertWeightKg != null ? String(data.alertWeightKg) : null, // Se l'utente ricalcola con misure nuove, sovrascriviamo; altrimenti // lasciamo il valore precedente. ...(bodyFatPct != null ? { bodyFatPct } : {}), diff --git a/apps/api/src/modules/tracking/check-in.routes.ts b/apps/api/src/modules/tracking/check-in.routes.ts new file mode 100644 index 0000000..43a150a --- /dev/null +++ b/apps/api/src/modules/tracking/check-in.routes.ts @@ -0,0 +1,89 @@ +// PRD §5.2 — check-in lampo quotidiano: indicatori soggettivi (energia, +// sonno, fame, umore) compilabili in 10 secondi, una volta al giorno. + +import { dailyCheckInInputSchema } from '@ketopath/shared'; +import type { FastifyPluginAsync } from 'fastify'; + +import { requireAuth } from '../../plugins/auth.js'; + +function todayDateOnly(): Date { + const d = new Date(); + d.setHours(0, 0, 0, 0); + return d; +} + +export const checkInRoutes: FastifyPluginAsync = async (fastify) => { + fastify.get('/me/check-ins', { preHandler: requireAuth() }, async (request) => { + const userId = request.user!.id; + const items = await fastify.prisma.dailyCheckIn.findMany({ + where: { userId }, + orderBy: { date: 'desc' }, + take: 30, + }); + return { + items: items.map((c) => ({ + id: c.id, + date: c.date.toISOString().slice(0, 10), + energy: c.energy, + sleep: c.sleep, + hunger: c.hunger, + mood: c.mood, + notes: c.notes, + })), + }; + }); + + // GET /me/check-ins/today: ritorna il check-in di oggi (se esiste). + fastify.get('/me/check-ins/today', { preHandler: requireAuth() }, async (request) => { + const userId = request.user!.id; + const date = todayDateOnly(); + const item = await fastify.prisma.dailyCheckIn.findUnique({ + where: { userId_date: { userId, date } }, + }); + if (!item) return { item: null }; + return { + item: { + id: item.id, + date: item.date.toISOString().slice(0, 10), + energy: item.energy, + sleep: item.sleep, + hunger: item.hunger, + mood: item.mood, + notes: item.notes, + }, + }; + }); + + fastify.post('/me/check-ins', { preHandler: requireAuth() }, async (request, reply) => { + const parsed = dailyCheckInInputSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues }); + } + const data = parsed.data; + const userId = request.user!.id; + const date = new Date(data.date); + date.setHours(0, 0, 0, 0); + + const item = await fastify.prisma.dailyCheckIn.upsert({ + where: { userId_date: { userId, date } }, + create: { + userId, + date, + energy: data.energy ?? null, + sleep: data.sleep ?? null, + hunger: data.hunger ?? null, + mood: data.mood ?? null, + notes: data.notes ?? null, + }, + update: { + energy: data.energy ?? null, + sleep: data.sleep ?? null, + hunger: data.hunger ?? null, + mood: data.mood ?? null, + notes: data.notes ?? null, + }, + }); + + return reply.code(201).send({ item: { id: item.id } }); + }); +}; diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index bae9b32..02a12bb 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -187,7 +187,27 @@ "saved": "Registrato.", "current": "Peso attuale", "delta": "Variazione", - "emptyHistory": "Nessuna rilevazione registrata. Inserisci la prima a destra: la tendenza apparirà dopo due o tre pesate." + "emptyHistory": "Nessuna rilevazione registrata. Inserisci la prima a destra: la tendenza apparirà dopo due o tre pesate.", + "weeklyTrend": "Trend settimanale", + "projection": "Al ritmo attuale raggiungerai {kg} kg il {date}.", + "alertEyebrow": "Soglia di allarme", + "alertMessage": "Il tuo peso è {current} kg, sopra la soglia di {threshold} kg che hai impostato.", + "alertHint": "Suggerimento: una settimana di riparazione — torna al deficit di Fase 1, idratazione alta, sonno regolare. Senza colpa: tornare in carreggiata è normale.", + "measurementsHelpToggle": "Come misurare bene?", + "measurementGuides": { + "waist": "Misura nel punto più stretto del busto, sopra l'ombelico, espirando senza forzare.", + "hips": "Nel punto più sporgente del bacino/glutei, in piedi e con i piedi uniti.", + "thigh": "Coscia dominante, a metà tra inguine e ginocchio, gamba leggermente flessa.", + "arm": "Braccio dominante, a metà tra spalla e gomito, braccio rilassato lungo il fianco.", + "tip": "Misura sempre alla stessa ora, possibilmente al mattino e con lo stesso metro a nastro morbido." + }, + "checkInEyebrow": "Check-in di oggi", + "checkInTitle": "Come ti senti oggi? Pochi secondi, una traccia nel tempo.", + "checkInSave": "Registra check-in", + "checkInUpdate": "Aggiorna check-in", + "checkInSaved": "Salvato.", + "checkInAlreadyDone": "Già compilato", + "mood": "Umore" }, "Onboarding": { "title": "Configura il tuo percorso", @@ -516,6 +536,8 @@ "hipsCm": "Fianchi", "targetWeeklyLossKg": "Velocità di calo (kg/settimana)", "targetWeeklyLossKgHint": "Suggerito: 0.4–0.7 kg/settimana. Oltre 1% del peso corporeo è considerato troppo aggressivo.", + "alertWeightKg": "Soglia di allarme peso (kg)", + "alertWeightKgHint": "Quando ci risali, KetoPath ti suggerisce una settimana di riparazione. Tipicamente 2-3 kg sopra l'obiettivo.", "dietHistory": "Storia delle diete precedenti", "dietHistoryHint": "Diete restrittive ripetute (yo-yo) abbassano il metabolismo basale del 5-15%. Indicarcelo ci serve a calibrare il deficit con prudenza.", "dietHistoryLabel": { diff --git a/apps/web/src/app/[locale]/profile/profile-form.tsx b/apps/web/src/app/[locale]/profile/profile-form.tsx index 0c76b1f..2fa8cd2 100644 --- a/apps/web/src/app/[locale]/profile/profile-form.tsx +++ b/apps/web/src/app/[locale]/profile/profile-form.tsx @@ -66,6 +66,7 @@ export function ProfileForm({ ? { targetWeeklyLossKg: initial.targetWeeklyLossKg } : {}), ...(initial.dietHistory ? { dietHistory: initial.dietHistory } : {}), + ...(initial.alertWeightKg != null ? { alertWeightKg: initial.alertWeightKg } : {}), }, } : {}), @@ -259,6 +260,31 @@ export function ProfileForm({ )} /> + ( + + {t('alertWeightKg')} + + + +

+ {t('alertWeightKgHint')} +

+ +
+ )} + /> + { + const res = await fetch(`${API_URL}/me/check-ins/today`, { + headers: { cookie: cookieHeader() }, + cache: 'no-store', + }); + if (!res.ok) return null; + const data = (await res.json()) as { item: DailyCheckInRow | null }; + return data.item; +} + +export interface CheckInInput { + energy?: number; + sleep?: number; + hunger?: number; + mood?: number; + notes?: string; +} + +export async function saveTodayCheckIn( + input: CheckInInput, +): Promise<{ ok: true } | { ok: false; error: string }> { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const res = await fetch(`${API_URL}/me/check-ins`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', cookie: cookieHeader() }, + body: JSON.stringify({ ...input, date: today.toISOString() }), + cache: 'no-store', + }); + if (!res.ok) return { ok: false, error: `api_error_${res.status}` }; + revalidatePath('/tracking'); + return { ok: true }; +} diff --git a/apps/web/src/app/[locale]/tracking/daily-check-in.tsx b/apps/web/src/app/[locale]/tracking/daily-check-in.tsx new file mode 100644 index 0000000..a11a5fb --- /dev/null +++ b/apps/web/src/app/[locale]/tracking/daily-check-in.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useState, useTransition } from 'react'; + +import { Button } from '@/components/ui/button'; + +import { saveTodayCheckIn, type DailyCheckInRow } from './actions'; + +export function DailyCheckIn({ initial }: { initial: DailyCheckInRow | null }) { + const t = useTranslations('Tracking'); + const [energy, setEnergy] = useState(initial?.energy ?? 5); + const [sleep, setSleep] = useState(initial?.sleep ?? 5); + const [hunger, setHunger] = useState(initial?.hunger ?? 5); + const [mood, setMood] = useState(initial?.mood ?? 5); + const [pending, startTransition] = useTransition(); + const [saved, setSaved] = useState(initial != null); + + function save(): void { + startTransition(async () => { + const result = await saveTodayCheckIn({ energy, sleep, hunger, mood }); + if (result.ok) { + setSaved(true); + window.setTimeout(() => setSaved(false), 2500); + } + }); + } + + return ( +
+
+
+

{t('checkInEyebrow')}

+

{t('checkInTitle')}

+
+ {initial ? ( + + {t('checkInAlreadyDone')} + + ) : null} +
+ +
+ + + + +
+ +
+ + {saved && !pending ? ( + + {t('checkInSaved')} + + ) : null} +
+
+ ); +} + +function Slider({ + label, + value, + onChange, +}: { + label: string; + value: number; + onChange: (n: number) => void; +}) { + return ( +
+
+

{label}

+

{value}/10

+
+ onChange(Number(e.target.value))} + className="mt-2 w-full cursor-pointer accent-current" + /> +
+ ); +} diff --git a/apps/web/src/app/[locale]/tracking/page.tsx b/apps/web/src/app/[locale]/tracking/page.tsx index d0c1417..9593a53 100644 --- a/apps/web/src/app/[locale]/tracking/page.tsx +++ b/apps/web/src/app/[locale]/tracking/page.tsx @@ -6,10 +6,18 @@ import { setRequestLocale } from 'next-intl/server'; import { Masthead } from '@/components/masthead'; import { getServerSession } from '@/lib/auth'; -import { fetchWeightEntries } from './actions'; +import { fetchProfile } from '../profile/actions'; + +import { fetchTodayCheckIn, fetchWeightEntries } from './actions'; +import { DailyCheckIn } from './daily-check-in'; import { WeightEntryForm } from './weight-entry-form'; import { WeightHistory } from './weight-history'; +type ProfileLike = { + weightGoalKg?: number; + alertWeightKg?: number | null; +} | null; + export default async function TrackingPage({ params: { locale } }: { params: { locale: string } }) { setRequestLocale(locale); const session = await getServerSession(); @@ -22,18 +30,35 @@ export default async function TrackingPage({ params: { locale } }: { params: { l if (!user?.disclaimerAcceptedAt) redirect('/welcome'); if (!user.profile) redirect('/onboarding'); - const entries = await fetchWeightEntries(); + const [entries, profile, todayCheckIn] = await Promise.all([ + fetchWeightEntries(), + fetchProfile(), + fetchTodayCheckIn(), + ]); - return ; + return ( + + ); } function TrackingPageContent({ entries, + profile, + todayCheckIn, }: { entries: Awaited>; + profile: ProfileLike; + todayCheckIn: Awaited>; }) { const t = useTranslations('Tracking'); const latest = entries[0] ?? null; + const goalKg = typeof profile?.weightGoalKg === 'number' ? profile.weightGoalKg : null; + const alertKg = typeof profile?.alertWeightKg === 'number' ? profile.alertWeightKg : null; + const alertActive = alertKg != null && latest != null && latest.weightKg >= alertKg; return (
@@ -50,6 +75,23 @@ function TrackingPageContent({
+ + + {alertActive && alertKg != null && latest != null ? ( + + ) : null} +

{t('newEntry')}

@@ -57,7 +99,7 @@ function TrackingPageContent({
diff --git a/apps/web/src/app/[locale]/tracking/weight-entry-form.tsx b/apps/web/src/app/[locale]/tracking/weight-entry-form.tsx index 7a7fa7a..f02b038 100644 --- a/apps/web/src/app/[locale]/tracking/weight-entry-form.tsx +++ b/apps/web/src/app/[locale]/tracking/weight-entry-form.tsx @@ -91,7 +91,37 @@ export function WeightEntryForm({ latest }: { latest: WeightEntryRow | null }) {
-

{t('measurements')}

+
+

{t('measurements')}

+
+ + {t('measurementsHelpToggle')} + +
+
    +
  • + {t('waist')}:{' '} + {t('measurementGuides.waist')} +
  • +
  • + {t('hips')}:{' '} + {t('measurementGuides.hips')} +
  • +
  • + {t('thigh')}:{' '} + {t('measurementGuides.thigh')} +
  • +
  • + {t('arm')}:{' '} + {t('measurementGuides.arm')} +
  • +
+

+ {t('measurementGuides.tip')} +

+
+
+
{( [ diff --git a/apps/web/src/app/[locale]/tracking/weight-history.tsx b/apps/web/src/app/[locale]/tracking/weight-history.tsx index b6886f0..d65bb62 100644 --- a/apps/web/src/app/[locale]/tracking/weight-history.tsx +++ b/apps/web/src/app/[locale]/tracking/weight-history.tsx @@ -1,3 +1,4 @@ +import { linearWeightTrend, projectGoalDate } from '@ketopath/shared'; import { useTranslations } from 'next-intl'; import type { WeightEntryRow } from './actions'; @@ -57,7 +58,19 @@ function Sparkline({ values, width = 280, height = 64 }: SparkProps) { ); } -export function WeightHistory({ entries }: { entries: WeightEntryRow[] }) { +const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', { + day: 'numeric', + month: 'long', + year: 'numeric', +}); + +export function WeightHistory({ + entries, + goalKg, +}: { + entries: WeightEntryRow[]; + goalKg?: number | null; +}) { const t = useTranslations('Tracking'); if (entries.length === 0) { return ( @@ -73,6 +86,18 @@ export function WeightHistory({ entries }: { entries: WeightEntryRow[] }) { const oldest = entries[entries.length - 1]!; const delta = newest.weightKg - oldest.weightKg; + const trend = linearWeightTrend( + [...entries].reverse().map((e) => ({ date: e.date, weightKg: e.weightKg })), + ); + const projectedDate = + trend && goalKg != null + ? projectGoalDate({ + currentKg: newest.weightKg, + goalKg, + slopeKgPerWeek: trend.slopeKgPerWeek, + }) + : null; + return (
@@ -83,7 +108,7 @@ export function WeightHistory({ entries }: { entries: WeightEntryRow[] }) {
-
+

{t('current')}

@@ -103,8 +128,32 @@ export function WeightHistory({ entries }: { entries: WeightEntryRow[] }) { kg

+ {trend && trend.n >= 2 ? ( +
+

{t('weeklyTrend')}

+

0 + ? 'text-pomodoro' + : 'text-ink' + }`} + > + {trend.slopeKgPerWeek > 0 ? '+' : ''} + {trend.slopeKgPerWeek.toFixed(2)} + kg/sett +

+
+ ) : null}
+ {projectedDate && goalKg != null ? ( +

+ {t('projection', { kg: goalKg.toFixed(1), date: ITALIAN_DATE.format(projectedDate) })} +

+ ) : null} +
    {entries.slice(0, 8).map((e) => (
  • ; diff --git a/packages/shared/src/tracking/schema.ts b/packages/shared/src/tracking/schema.ts index 8799271..2516b6d 100644 --- a/packages/shared/src/tracking/schema.ts +++ b/packages/shared/src/tracking/schema.ts @@ -20,6 +20,18 @@ const measurementsSchema = z }) .strict(); +// PRD §5.2 — check-in lampo quotidiano. Form 10-secondi: solo soggettivi. +export const dailyCheckInInputSchema = z.object({ + date: z.coerce.date(), + 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(), + mood: z.coerce.number().int().min(1).max(10).optional(), + notes: z.string().max(500).optional(), +}); + +export type DailyCheckInInput = z.input; + export const weightEntryInputSchema = z.object({ date: z.coerce.date(), weightKg: z.coerce.number().min(35).max(300), diff --git a/packages/shared/src/tracking/weight-trend.test.ts b/packages/shared/src/tracking/weight-trend.test.ts new file mode 100644 index 0000000..d23d9f6 --- /dev/null +++ b/packages/shared/src/tracking/weight-trend.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { linearWeightTrend, projectGoalDate } from './weight-trend.js'; + +const WEEK = 7 * 24 * 60 * 60 * 1000; + +describe('linearWeightTrend', () => { + it('vuoto → null', () => { + expect(linearWeightTrend([])).toBeNull(); + }); + + it('1 punto → slope 0', () => { + const t = linearWeightTrend([{ date: new Date(), weightKg: 80 }]); + expect(t?.slopeKgPerWeek).toBe(0); + expect(t?.interceptKg).toBe(80); + }); + + it('serie discendente perfetta: -0.5 kg/settimana', () => { + const t0 = new Date('2026-01-01').getTime(); + const points = [0, 1, 2, 3, 4].map((w) => ({ + date: new Date(t0 + w * WEEK), + weightKg: 80 - 0.5 * w, + })); + const t = linearWeightTrend(points); + expect(t?.slopeKgPerWeek).toBeCloseTo(-0.5, 4); + expect(t?.interceptKg).toBeCloseTo(80, 4); + }); + + it('serie ascendente: slope positivo', () => { + const t0 = new Date('2026-01-01').getTime(); + const points = [ + { date: new Date(t0), weightKg: 70 }, + { date: new Date(t0 + WEEK), weightKg: 70.3 }, + { date: new Date(t0 + 2 * WEEK), weightKg: 70.7 }, + ]; + const t = linearWeightTrend(points); + expect(t?.slopeKgPerWeek).toBeGreaterThan(0); + }); +}); + +describe('projectGoalDate', () => { + const now = new Date('2026-04-29T00:00:00Z'); + + it('slope 0 → null', () => { + expect(projectGoalDate({ currentKg: 80, goalKg: 75, slopeKgPerWeek: 0, now })).toBeNull(); + }); + + it('trend va nella direzione giusta → data nel futuro', () => { + const date = projectGoalDate({ + currentKg: 80, + goalKg: 75, + slopeKgPerWeek: -0.5, + now, + }); + expect(date).not.toBeNull(); + // 5 kg / 0.5 = 10 settimane + const expectedMs = now.getTime() + 10 * WEEK; + expect(Math.abs(date!.getTime() - expectedMs)).toBeLessThan(1000); + }); + + it('trend opposto → null (segno contrario)', () => { + expect(projectGoalDate({ currentKg: 80, goalKg: 75, slopeKgPerWeek: 0.3, now })).toBeNull(); + }); + + it('oltre 52 settimane → null', () => { + expect(projectGoalDate({ currentKg: 80, goalKg: 75, slopeKgPerWeek: -0.05, now })).toBeNull(); + }); + + it('currentKg == goalKg → now', () => { + const r = projectGoalDate({ currentKg: 75, goalKg: 75, slopeKgPerWeek: -0.5, now }); + expect(r).toEqual(now); + }); +}); diff --git a/packages/shared/src/tracking/weight-trend.ts b/packages/shared/src/tracking/weight-trend.ts new file mode 100644 index 0000000..74225a4 --- /dev/null +++ b/packages/shared/src/tracking/weight-trend.ts @@ -0,0 +1,79 @@ +// PRD §5.2 — Trend del peso e proiezione data obiettivo. +// +// Regressione lineare semplice (least squares) sui WeightEntry. Output: +// pendenza in kg/settimana e intercetta. Da lì calcoliamo la data +// proiettata al raggiungimento dell'obiettivo, sotto vincoli di sicurezza +// (slope di segno coerente, max 12 mesi di proiezione). + +export interface WeightPoint { + date: Date | string; + weightKg: number; +} + +export interface WeightTrend { + slopeKgPerWeek: number; + interceptKg: number; + /** Numero di punti utilizzati. */ + n: number; +} + +const WEEK_MS = 7 * 24 * 60 * 60 * 1000; + +function toDate(d: Date | string): Date { + return typeof d === 'string' ? new Date(d) : d; +} + +/** + * Regressione lineare con t in settimane dal primo punto. Servono almeno 2 + * punti distinti — altrimenti slope=0 e intercept=peso unico. + */ +export function linearWeightTrend(points: ReadonlyArray): WeightTrend | null { + if (points.length === 0) return null; + const sorted = [...points].sort((a, b) => toDate(a.date).getTime() - toDate(b.date).getTime()); + const first = sorted[0]!; + if (sorted.length === 1) { + return { slopeKgPerWeek: 0, interceptKg: first.weightKg, n: 1 }; + } + + const t0 = toDate(first.date).getTime(); + const xs = sorted.map((p) => (toDate(p.date).getTime() - t0) / WEEK_MS); + const ys = sorted.map((p) => p.weightKg); + const n = xs.length; + const sumX = xs.reduce((a, b) => a + b, 0); + const sumY = ys.reduce((a, b) => a + b, 0); + const sumXY = xs.reduce((acc, x, i) => acc + x * (ys[i] ?? 0), 0); + const sumXX = xs.reduce((acc, x) => acc + x * x, 0); + const denom = n * sumXX - sumX * sumX; + if (denom === 0) { + return { slopeKgPerWeek: 0, interceptKg: sumY / n, n }; + } + const slope = (n * sumXY - sumX * sumY) / denom; + const intercept = (sumY - slope * sumX) / n; + return { slopeKgPerWeek: slope, interceptKg: intercept, n }; +} + +/** + * Data proiettata al raggiungimento di `goalKg`. Ritorna null se: + * - slope è zero, o + * - slope ha segno opposto a quello necessario (es. perdere peso ma il trend cresce), o + * - la data risulta oltre 12 mesi (rumorosa). + */ +export function projectGoalDate(args: { + currentKg: number; + goalKg: number; + slopeKgPerWeek: number; + now?: Date; +}): Date | null { + const { currentKg, goalKg, slopeKgPerWeek, now = new Date() } = args; + if (slopeKgPerWeek === 0) return null; + const delta = goalKg - currentKg; + if (delta === 0) return now; + // Direzioni opposte → trend non sta andando verso l'obiettivo. + if (Math.sign(delta) !== Math.sign(slopeKgPerWeek)) return null; + const weeks = delta / slopeKgPerWeek; + if (weeks <= 0) return null; + const ms = weeks * WEEK_MS; + // Cap a 12 mesi di proiezione realistica (52 settimane). + if (weeks > 52) return null; + return new Date(now.getTime() + ms); +}