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

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