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 { 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 } });
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
|
||||
@@ -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';
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user