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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user