feat: §5.2 batch — trend lineare, soglia allarme, guide misure, daily check-in

Quattro feature del PRD §5.2 Tracking peso e progressi.

§5.2.1 Trend regressione lineare + proiezione
- @ketopath/shared/tracking/weight-trend: linearWeightTrend() (least squares),
  projectGoalDate() con cap a 52 settimane e check di direzione coerente
- 9 unit test (slope/intercept, segno, cap)
- /tracking: terza statistica "Trend settimanale" + frase
  "Al ritmo attuale raggiungerai 75.0 kg il 24 luglio 2026"

§5.2.2 Soglia di allarme peso personalizzabile
- Profile.alertWeightKg @encrypted
- profileInputSchema accetta il campo, profile.routes lo persiste
- ProfileForm: nuovo input "Soglia di allarme peso (kg)"
- /tracking: banner pomodoro "Sei sopra la soglia" con suggerimento
  "settimana di riparazione" senza colpa

§5.2.3 Linee guida "come misurare bene"
- Disclosure inline in WeightEntryForm con istruzioni tecniche per
  girovita, fianchi, coscia, braccio + tip generale (ora del giorno,
  metro morbido, stessa procedura)

§5.2.4 Daily check-in indicatori soggettivi
- Nuovo modello DailyCheckIn (date, energy, sleep, hunger, mood, notes)
  con unique(userId, date) e migration
- shared dailyCheckInInputSchema (zod)
- API: GET /me/check-ins (last 30), GET /me/check-ins/today,
  POST /me/check-ins (upsert sul giorno)
- /tracking: card lampo in cima con 4 slider 1-10 + Salva/Aggiorna,
  10 secondi di compilazione

i18n: namespace Tracking esteso (weeklyTrend, projection, alertEyebrow/Message/Hint,
measurementsHelpToggle + measurementGuides{}, checkIn*, mood). Profile esteso
(alertWeightKg + hint).

Schema migrations: add_alert_weight_kg, add_daily_check_in.

81/81 unit test verdi (9 nuovi). Lint, typecheck verdi.

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 23:50:05 +02:00
1 parent ea88ed8775
commit 9327e75395
18 files changed
+627 -8

No files matched your search

+2
View File
@@ -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<FastifyInstance> {
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);
@@ -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 } : {}),
@@ -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 } });
});
};
+23 -1
View File
@@ -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": {
@@ -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({
)}
/>
<FormField
control={form.control}
name="alertWeightKg"
render={({ field }) => (
<FormItem className="space-y-3">
<FormLabel>{t('alertWeightKg')}</FormLabel>
<FormControl>
<Input
type="number"
step="0.1"
min={35}
max={300}
inputMode="decimal"
{...field}
value={(field.value as number | undefined) ?? ''}
/>
</FormControl>
<p className="font-display text-ink-soft text-sm italic leading-snug">
{t('alertWeightKgHint')}
</p>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="dietHistory"
@@ -61,3 +61,47 @@ export async function saveWeightEntry(input: WeightEntryInput): Promise<SaveResu
revalidatePath('/tracking');
return { ok: true };
}
export interface DailyCheckInRow {
id: string;
date: string;
energy: number | null;
sleep: number | null;
hunger: number | null;
mood: number | null;
notes: string | null;
}
export async function fetchTodayCheckIn(): Promise<DailyCheckInRow | null> {
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 };
}
@@ -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<number>(initial?.energy ?? 5);
const [sleep, setSleep] = useState<number>(initial?.sleep ?? 5);
const [hunger, setHunger] = useState<number>(initial?.hunger ?? 5);
const [mood, setMood] = useState<number>(initial?.mood ?? 5);
const [pending, startTransition] = useTransition();
const [saved, setSaved] = useState<boolean>(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 (
<section className="border-ink/15 bg-carta-light/40 mb-10 border p-5">
<div className="flex flex-wrap items-baseline justify-between gap-4">
<div>
<p className="editorial-eyebrow">{t('checkInEyebrow')}</p>
<p className="font-display text-ink mt-2 text-base leading-snug">{t('checkInTitle')}</p>
</div>
{initial ? (
<span className="text-ink-soft font-mono text-[10px] uppercase tracking-widest">
{t('checkInAlreadyDone')}
</span>
) : null}
</div>
<div className="mt-5 grid grid-cols-2 gap-x-6 gap-y-5 sm:grid-cols-4">
<Slider label={t('energy')} value={energy} onChange={setEnergy} />
<Slider label={t('sleep')} value={sleep} onChange={setSleep} />
<Slider label={t('hunger')} value={hunger} onChange={setHunger} />
<Slider label={t('mood')} value={mood} onChange={setMood} />
</div>
<div className="mt-5 flex flex-wrap items-center gap-3">
<Button type="button" variant="outline" onClick={save} disabled={pending} size="sm">
{pending ? t('saving') : initial ? t('checkInUpdate') : t('checkInSave')}
</Button>
{saved && !pending ? (
<span className="font-display text-oliva text-sm italic" role="status">
{t('checkInSaved')}
</span>
) : null}
</div>
</section>
);
}
function Slider({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (n: number) => void;
}) {
return (
<div>
<div className="flex items-baseline justify-between gap-2">
<p className="editorial-eyebrow">{label}</p>
<p className="text-ink font-mono text-sm tabular-nums">{value}/10</p>
</div>
<input
type="range"
min={1}
max={10}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="mt-2 w-full cursor-pointer accent-current"
/>
</div>
);
}
+46 -4
View File
@@ -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 <TrackingPageContent entries={entries} />;
return (
<TrackingPageContent
entries={entries}
profile={profile as ProfileLike}
todayCheckIn={todayCheckIn}
/>
);
}
function TrackingPageContent({
entries,
profile,
todayCheckIn,
}: {
entries: Awaited<ReturnType<typeof fetchWeightEntries>>;
profile: ProfileLike;
todayCheckIn: Awaited<ReturnType<typeof fetchTodayCheckIn>>;
}) {
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 (
<div className="mx-auto min-h-screen max-w-5xl px-6 sm:px-10">
@@ -50,6 +75,23 @@ function TrackingPageContent({
<div className="rule animate-rule-in my-10 [animation-delay:360ms]" />
<DailyCheckIn initial={todayCheckIn} />
{alertActive && alertKg != null && latest != null ? (
<aside className="border-pomodoro/40 bg-pomodoro/5 mb-10 border-2 border-dashed p-5">
<p className="editorial-eyebrow">{t('alertEyebrow')}</p>
<p className="font-display text-ink mt-3 text-base leading-snug">
{t('alertMessage', {
current: latest.weightKg.toFixed(1),
threshold: alertKg.toFixed(1),
})}
</p>
<p className="font-display text-ink-soft mt-2 text-sm italic leading-snug">
{t('alertHint')}
</p>
</aside>
) : null}
<div className="grid gap-12 md:grid-cols-12">
<section className="animate-fade-up [animation-delay:300ms] md:col-span-7">
<p className="editorial-eyebrow mb-6">{t('newEntry')}</p>
@@ -57,7 +99,7 @@ function TrackingPageContent({
</section>
<aside className="md:border-ink/15 animate-fade-up [animation-delay:420ms] md:col-span-5 md:border-l md:pl-10">
<p className="editorial-eyebrow mb-6">{t('history')}</p>
<WeightHistory entries={entries} />
<WeightHistory entries={entries} goalKg={goalKg} />
</aside>
</div>
</main>
@@ -91,7 +91,37 @@ export function WeightEntryForm({ latest }: { latest: WeightEntryRow | null }) {
</div>
<div className="space-y-6">
<p className="editorial-eyebrow">{t('measurements')}</p>
<div className="flex items-baseline justify-between gap-4">
<p className="editorial-eyebrow">{t('measurements')}</p>
<details className="group">
<summary className="text-ink-soft hover:text-ink cursor-pointer list-none font-mono text-[10px] uppercase tracking-widest underline decoration-[1.5px] underline-offset-[5px]">
{t('measurementsHelpToggle')}
</summary>
<div className="border-ink/15 bg-carta-light/40 mt-3 max-w-md border p-4">
<ul className="font-display text-ink-soft space-y-3 text-sm leading-relaxed">
<li>
<strong className="text-ink not-italic">{t('waist')}</strong>:{' '}
{t('measurementGuides.waist')}
</li>
<li>
<strong className="text-ink not-italic">{t('hips')}</strong>:{' '}
{t('measurementGuides.hips')}
</li>
<li>
<strong className="text-ink not-italic">{t('thigh')}</strong>:{' '}
{t('measurementGuides.thigh')}
</li>
<li>
<strong className="text-ink not-italic">{t('arm')}</strong>:{' '}
{t('measurementGuides.arm')}
</li>
</ul>
<p className="font-display text-ink-dim mt-4 text-xs italic leading-snug">
{t('measurementGuides.tip')}
</p>
</div>
</details>
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-8 sm:grid-cols-4">
{(
[
@@ -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 (
<div className="space-y-8">
<div>
@@ -83,7 +108,7 @@ export function WeightHistory({ entries }: { entries: WeightEntryRow[] }) {
</div>
</div>
<div className="border-ink/10 grid grid-cols-2 gap-6 border-y py-5">
<div className="border-ink/10 grid grid-cols-2 gap-6 border-y py-5 sm:grid-cols-3">
<div>
<p className="editorial-eyebrow">{t('current')}</p>
<p className="text-ink mt-2 font-mono text-3xl font-medium leading-none tracking-tight">
@@ -103,8 +128,32 @@ export function WeightHistory({ entries }: { entries: WeightEntryRow[] }) {
<span className="font-display text-ink-soft ml-1 text-xs italic">kg</span>
</p>
</div>
{trend && trend.n >= 2 ? (
<div>
<p className="editorial-eyebrow">{t('weeklyTrend')}</p>
<p
className={`mt-2 font-mono text-3xl font-medium leading-none tracking-tight ${
trend.slopeKgPerWeek < 0
? 'text-oliva'
: trend.slopeKgPerWeek > 0
? 'text-pomodoro'
: 'text-ink'
}`}
>
{trend.slopeKgPerWeek > 0 ? '+' : ''}
{trend.slopeKgPerWeek.toFixed(2)}
<span className="font-display text-ink-soft ml-1 text-xs italic">kg/sett</span>
</p>
</div>
) : null}
</div>
{projectedDate && goalKg != null ? (
<p className="font-display text-ink-soft text-base italic leading-snug">
{t('projection', { kg: goalKg.toFixed(1), date: ITALIAN_DATE.format(projectedDate) })}
</p>
) : null}
<ul className="space-y-3">
{entries.slice(0, 8).map((e) => (
<li
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "profiles" ADD COLUMN "alert_weight_kg" TEXT;
@@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "daily_check_ins" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"date" DATE NOT NULL,
"energy" INTEGER,
"sleep" INTEGER,
"hunger" INTEGER,
"mood" INTEGER,
"notes" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "daily_check_ins_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "daily_check_ins_user_id_date_idx" ON "daily_check_ins"("user_id", "date");
-- CreateIndex
CREATE UNIQUE INDEX "daily_check_ins_user_id_date_key" ON "daily_check_ins"("user_id", "date");
-- AddForeignKey
ALTER TABLE "daily_check_ins" ADD CONSTRAINT "daily_check_ins_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+28
View File
@@ -100,6 +100,7 @@ model User {
sessions Session[]
accounts Account[]
weightEntries WeightEntry[]
dailyCheckIns DailyCheckIn[]
fastEvents FastEvent[]
mealPlans MealPlan[]
deviceTokens DeviceToken[]
@@ -187,6 +188,10 @@ model Profile {
// PRD §5.1 — % grasso corporeo stimata (US Navy) per BMR Katch-McArdle.
/// @encrypted
bodyFatPct String? @map("body_fat_pct")
// PRD §5.2 — soglia di allarme peso. Quando il peso corrente la supera,
// mostriamo un banner di "settimana di riparazione" in /tracking.
/// @encrypted
alertWeightKg String? @map("alert_weight_kg")
// PRD §5.1 — storia delle diete restrittive precedenti, per stimare
// l'adattamento metabolico (Fothergill 2016, Rosenbaum 2008). Categoriale:
// NONE / SOME / EXTENSIVE / SEVERE. Cifrato perché può alludere a
@@ -228,6 +233,29 @@ model Preferences {
// PRD §5.2 — pesata settimanale, misure, indicatori soggettivi.
// I campi sanitari sono cifrati at-rest (ADR 0002).
// PRD §5.2 — check-in lampo quotidiano (10s): solo indicatori soggettivi.
// Distinto da WeightEntry (settimanale, con peso e misure).
model DailyCheckIn {
id String @id @default(cuid())
userId String @map("user_id")
date DateTime @db.Date
energy Int? // 1..10
sleep Int? // 1..10
hunger Int? // 1..10
mood Int? // 1..10
/// @encrypted
notes String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, date])
@@index([userId, date])
@@map("daily_check_ins")
}
model WeightEntry {
id String @id @default(cuid())
userId String @map("user_id")
+1
View File
@@ -15,3 +15,4 @@ export * from './profile/diet-history.js';
export * from './profile/schema.js';
export * from './tracking/fasting-stats.js';
export * from './tracking/schema.js';
export * from './tracking/weight-trend.js';
+3
View File
@@ -27,6 +27,9 @@ export const profileInputSchema = z.object({
hipsCm: z.coerce.number().min(40).max(200).optional(),
// Storia delle diete restrittive precedenti (vedi diet-history.ts).
dietHistory: z.enum(DIET_HISTORIES).optional(),
// PRD §5.2 — soglia di allarme peso (kg). Tipicamente posta 2-3 kg sopra
// il peso obiettivo per intervenire prima del recupero.
alertWeightKg: z.coerce.number().min(35).max(300).optional(),
});
export type ProfileInput = z.input<typeof profileInputSchema>;
+12
View File
@@ -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<typeof dailyCheckInInputSchema>;
export const weightEntryInputSchema = z.object({
date: z.coerce.date(),
weightKg: z.coerce.number().min(35).max(300),
@@ -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);
});
});
@@ -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<WeightPoint>): 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);
}