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:
1 parent
ea88ed8775
commit
9327e75395
18 files changed
+627
-8
No files matched your search
@@ -14,6 +14,7 @@ import { planRoutes } from './modules/plan/plan.routes.js';
|
|||||||
import { preferencesRoutes } from './modules/preferences/preferences.routes.js';
|
import { preferencesRoutes } from './modules/preferences/preferences.routes.js';
|
||||||
import { profileRoutes } from './modules/profile/profile.routes.js';
|
import { profileRoutes } from './modules/profile/profile.routes.js';
|
||||||
import { shoppingRoutes } from './modules/shopping/shopping.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 { fastRoutes } from './modules/tracking/fast.routes.js';
|
||||||
import { weightRoutes } from './modules/tracking/weight.routes.js';
|
import { weightRoutes } from './modules/tracking/weight.routes.js';
|
||||||
import { authPlugin } from './plugins/auth.js';
|
import { authPlugin } from './plugins/auth.js';
|
||||||
@@ -55,6 +56,7 @@ export async function buildApp(): Promise<FastifyInstance> {
|
|||||||
await app.register(preferencesRoutes);
|
await app.register(preferencesRoutes);
|
||||||
await app.register(weightRoutes);
|
await app.register(weightRoutes);
|
||||||
await app.register(fastRoutes);
|
await app.register(fastRoutes);
|
||||||
|
await app.register(checkInRoutes);
|
||||||
await app.register(planRoutes);
|
await app.register(planRoutes);
|
||||||
await app.register(shoppingRoutes);
|
await app.register(shoppingRoutes);
|
||||||
await app.register(notificationsRoutes);
|
await app.register(notificationsRoutes);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ function serialize(
|
|||||||
targetWeeklyLossKg: number | null;
|
targetWeeklyLossKg: number | null;
|
||||||
medicalConditions: string | null;
|
medicalConditions: string | null;
|
||||||
bodyFatPct: string | null;
|
bodyFatPct: string | null;
|
||||||
|
alertWeightKg: string | null;
|
||||||
dietHistory: string | null;
|
dietHistory: string | null;
|
||||||
currentPhase: string;
|
currentPhase: string;
|
||||||
} | null,
|
} | null,
|
||||||
@@ -56,6 +57,7 @@ function serialize(
|
|||||||
targetWeeklyLossKg: profile.targetWeeklyLossKg,
|
targetWeeklyLossKg: profile.targetWeeklyLossKg,
|
||||||
medicalConditions: parseConditions(profile.medicalConditions),
|
medicalConditions: parseConditions(profile.medicalConditions),
|
||||||
bodyFatPct,
|
bodyFatPct,
|
||||||
|
alertWeightKg: profile.alertWeightKg ? Number(profile.alertWeightKg) : null,
|
||||||
dietHistory: profile.dietHistory,
|
dietHistory: profile.dietHistory,
|
||||||
currentPhase: profile.currentPhase,
|
currentPhase: profile.currentPhase,
|
||||||
derived: {
|
derived: {
|
||||||
@@ -130,6 +132,7 @@ export const profileRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
targetWeeklyLossKg: data.targetWeeklyLossKg ?? null,
|
targetWeeklyLossKg: data.targetWeeklyLossKg ?? null,
|
||||||
medicalConditions: conditionsJson,
|
medicalConditions: conditionsJson,
|
||||||
dietHistory: data.dietHistory ?? null,
|
dietHistory: data.dietHistory ?? null,
|
||||||
|
alertWeightKg: data.alertWeightKg != null ? String(data.alertWeightKg) : null,
|
||||||
bodyFatPct,
|
bodyFatPct,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
@@ -144,6 +147,7 @@ export const profileRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
targetWeeklyLossKg: data.targetWeeklyLossKg ?? null,
|
targetWeeklyLossKg: data.targetWeeklyLossKg ?? null,
|
||||||
medicalConditions: conditionsJson,
|
medicalConditions: conditionsJson,
|
||||||
dietHistory: data.dietHistory ?? null,
|
dietHistory: data.dietHistory ?? null,
|
||||||
|
alertWeightKg: data.alertWeightKg != null ? String(data.alertWeightKg) : null,
|
||||||
// Se l'utente ricalcola con misure nuove, sovrascriviamo; altrimenti
|
// Se l'utente ricalcola con misure nuove, sovrascriviamo; altrimenti
|
||||||
// lasciamo il valore precedente.
|
// lasciamo il valore precedente.
|
||||||
...(bodyFatPct != null ? { bodyFatPct } : {}),
|
...(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 } });
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -187,7 +187,27 @@
|
|||||||
"saved": "Registrato.",
|
"saved": "Registrato.",
|
||||||
"current": "Peso attuale",
|
"current": "Peso attuale",
|
||||||
"delta": "Variazione",
|
"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": {
|
"Onboarding": {
|
||||||
"title": "Configura il tuo percorso",
|
"title": "Configura il tuo percorso",
|
||||||
@@ -516,6 +536,8 @@
|
|||||||
"hipsCm": "Fianchi",
|
"hipsCm": "Fianchi",
|
||||||
"targetWeeklyLossKg": "Velocità di calo (kg/settimana)",
|
"targetWeeklyLossKg": "Velocità di calo (kg/settimana)",
|
||||||
"targetWeeklyLossKgHint": "Suggerito: 0.4–0.7 kg/settimana. Oltre 1% del peso corporeo è considerato troppo aggressivo.",
|
"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",
|
"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.",
|
"dietHistoryHint": "Diete restrittive ripetute (yo-yo) abbassano il metabolismo basale del 5-15%. Indicarcelo ci serve a calibrare il deficit con prudenza.",
|
||||||
"dietHistoryLabel": {
|
"dietHistoryLabel": {
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export function ProfileForm({
|
|||||||
? { targetWeeklyLossKg: initial.targetWeeklyLossKg }
|
? { targetWeeklyLossKg: initial.targetWeeklyLossKg }
|
||||||
: {}),
|
: {}),
|
||||||
...(initial.dietHistory ? { dietHistory: initial.dietHistory } : {}),
|
...(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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="dietHistory"
|
name="dietHistory"
|
||||||
|
|||||||
@@ -61,3 +61,47 @@ export async function saveWeightEntry(input: WeightEntryInput): Promise<SaveResu
|
|||||||
revalidatePath('/tracking');
|
revalidatePath('/tracking');
|
||||||
return { ok: true };
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,10 +6,18 @@ import { setRequestLocale } from 'next-intl/server';
|
|||||||
import { Masthead } from '@/components/masthead';
|
import { Masthead } from '@/components/masthead';
|
||||||
import { getServerSession } from '@/lib/auth';
|
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 { WeightEntryForm } from './weight-entry-form';
|
||||||
import { WeightHistory } from './weight-history';
|
import { WeightHistory } from './weight-history';
|
||||||
|
|
||||||
|
type ProfileLike = {
|
||||||
|
weightGoalKg?: number;
|
||||||
|
alertWeightKg?: number | null;
|
||||||
|
} | null;
|
||||||
|
|
||||||
export default async function TrackingPage({ params: { locale } }: { params: { locale: string } }) {
|
export default async function TrackingPage({ params: { locale } }: { params: { locale: string } }) {
|
||||||
setRequestLocale(locale);
|
setRequestLocale(locale);
|
||||||
const session = await getServerSession();
|
const session = await getServerSession();
|
||||||
@@ -22,18 +30,35 @@ export default async function TrackingPage({ params: { locale } }: { params: { l
|
|||||||
if (!user?.disclaimerAcceptedAt) redirect('/welcome');
|
if (!user?.disclaimerAcceptedAt) redirect('/welcome');
|
||||||
if (!user.profile) redirect('/onboarding');
|
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({
|
function TrackingPageContent({
|
||||||
entries,
|
entries,
|
||||||
|
profile,
|
||||||
|
todayCheckIn,
|
||||||
}: {
|
}: {
|
||||||
entries: Awaited<ReturnType<typeof fetchWeightEntries>>;
|
entries: Awaited<ReturnType<typeof fetchWeightEntries>>;
|
||||||
|
profile: ProfileLike;
|
||||||
|
todayCheckIn: Awaited<ReturnType<typeof fetchTodayCheckIn>>;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations('Tracking');
|
const t = useTranslations('Tracking');
|
||||||
const latest = entries[0] ?? null;
|
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 (
|
return (
|
||||||
<div className="mx-auto min-h-screen max-w-5xl px-6 sm:px-10">
|
<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]" />
|
<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">
|
<div className="grid gap-12 md:grid-cols-12">
|
||||||
<section className="animate-fade-up [animation-delay:300ms] md:col-span-7">
|
<section className="animate-fade-up [animation-delay:300ms] md:col-span-7">
|
||||||
<p className="editorial-eyebrow mb-6">{t('newEntry')}</p>
|
<p className="editorial-eyebrow mb-6">{t('newEntry')}</p>
|
||||||
@@ -57,7 +99,7 @@ function TrackingPageContent({
|
|||||||
</section>
|
</section>
|
||||||
<aside className="md:border-ink/15 animate-fade-up [animation-delay:420ms] md:col-span-5 md:border-l md:pl-10">
|
<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>
|
<p className="editorial-eyebrow mb-6">{t('history')}</p>
|
||||||
<WeightHistory entries={entries} />
|
<WeightHistory entries={entries} goalKg={goalKg} />
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -91,7 +91,37 @@ export function WeightEntryForm({ latest }: { latest: WeightEntryRow | null }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<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">
|
<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 { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
import type { WeightEntryRow } from './actions';
|
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');
|
const t = useTranslations('Tracking');
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -73,6 +86,18 @@ export function WeightHistory({ entries }: { entries: WeightEntryRow[] }) {
|
|||||||
const oldest = entries[entries.length - 1]!;
|
const oldest = entries[entries.length - 1]!;
|
||||||
const delta = newest.weightKg - oldest.weightKg;
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
@@ -83,7 +108,7 @@ export function WeightHistory({ entries }: { entries: WeightEntryRow[] }) {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<div>
|
||||||
<p className="editorial-eyebrow">{t('current')}</p>
|
<p className="editorial-eyebrow">{t('current')}</p>
|
||||||
<p className="text-ink mt-2 font-mono text-3xl font-medium leading-none tracking-tight">
|
<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>
|
<span className="font-display text-ink-soft ml-1 text-xs italic">kg</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</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">
|
<ul className="space-y-3">
|
||||||
{entries.slice(0, 8).map((e) => (
|
{entries.slice(0, 8).map((e) => (
|
||||||
<li
|
<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;
|
||||||
@@ -100,6 +100,7 @@ model User {
|
|||||||
sessions Session[]
|
sessions Session[]
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
weightEntries WeightEntry[]
|
weightEntries WeightEntry[]
|
||||||
|
dailyCheckIns DailyCheckIn[]
|
||||||
fastEvents FastEvent[]
|
fastEvents FastEvent[]
|
||||||
mealPlans MealPlan[]
|
mealPlans MealPlan[]
|
||||||
deviceTokens DeviceToken[]
|
deviceTokens DeviceToken[]
|
||||||
@@ -187,6 +188,10 @@ model Profile {
|
|||||||
// PRD §5.1 — % grasso corporeo stimata (US Navy) per BMR Katch-McArdle.
|
// PRD §5.1 — % grasso corporeo stimata (US Navy) per BMR Katch-McArdle.
|
||||||
/// @encrypted
|
/// @encrypted
|
||||||
bodyFatPct String? @map("body_fat_pct")
|
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
|
// PRD §5.1 — storia delle diete restrittive precedenti, per stimare
|
||||||
// l'adattamento metabolico (Fothergill 2016, Rosenbaum 2008). Categoriale:
|
// l'adattamento metabolico (Fothergill 2016, Rosenbaum 2008). Categoriale:
|
||||||
// NONE / SOME / EXTENSIVE / SEVERE. Cifrato perché può alludere a
|
// NONE / SOME / EXTENSIVE / SEVERE. Cifrato perché può alludere a
|
||||||
@@ -228,6 +233,29 @@ model Preferences {
|
|||||||
|
|
||||||
// PRD §5.2 — pesata settimanale, misure, indicatori soggettivi.
|
// PRD §5.2 — pesata settimanale, misure, indicatori soggettivi.
|
||||||
// I campi sanitari sono cifrati at-rest (ADR 0002).
|
// 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 {
|
model WeightEntry {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ export * from './profile/diet-history.js';
|
|||||||
export * from './profile/schema.js';
|
export * from './profile/schema.js';
|
||||||
export * from './tracking/fasting-stats.js';
|
export * from './tracking/fasting-stats.js';
|
||||||
export * from './tracking/schema.js';
|
export * from './tracking/schema.js';
|
||||||
|
export * from './tracking/weight-trend.js';
|
||||||
@@ -27,6 +27,9 @@ export const profileInputSchema = z.object({
|
|||||||
hipsCm: z.coerce.number().min(40).max(200).optional(),
|
hipsCm: z.coerce.number().min(40).max(200).optional(),
|
||||||
// Storia delle diete restrittive precedenti (vedi diet-history.ts).
|
// Storia delle diete restrittive precedenti (vedi diet-history.ts).
|
||||||
dietHistory: z.enum(DIET_HISTORIES).optional(),
|
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>;
|
export type ProfileInput = z.input<typeof profileInputSchema>;
|
||||||
|
|||||||
@@ -20,6 +20,18 @@ const measurementsSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.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({
|
export const weightEntryInputSchema = z.object({
|
||||||
date: z.coerce.date(),
|
date: z.coerce.date(),
|
||||||
weightKg: z.coerce.number().min(35).max(300),
|
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);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user