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

+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>