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