diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json
index 6edeeeb..583b64c 100644
--- a/apps/web/messages/it.json
+++ b/apps/web/messages/it.json
@@ -9,6 +9,7 @@
"signOut": "Esci",
"completeProfile": "Profilo & calcoli",
"viewPlan": "Piano della settimana",
+ "viewTracking": "Pesata & misure",
"lettura": "Continua la lettura",
"inizia": "Inizia",
"greeting": "Buongiorno, {name}.",
@@ -68,6 +69,29 @@
"disclaimer": "Procedendo accetti che i tuoi dati siano trattati come descritto nell'informativa sulla privacy. KetoPath non sostituisce il parere medico."
}
},
+ "Tracking": {
+ "title": "Pesata e misure",
+ "subtitle": "Pochi numeri, una volta a settimana. La tendenza emerge dalla regolarità — non dalla pesata di oggi.",
+ "newEntry": "Nuova rilevazione",
+ "history": "Storico",
+ "date": "Data",
+ "weightKg": "Peso (kg)",
+ "measurements": "Misure (cm) — opzionali",
+ "waist": "Girovita",
+ "hips": "Fianchi",
+ "thigh": "Coscia",
+ "arm": "Braccio",
+ "subjective": "Indicatori soggettivi (1–10)",
+ "energy": "Energia",
+ "sleep": "Sonno",
+ "hunger": "Fame",
+ "save": "Registra",
+ "saving": "Salvataggio…",
+ "saved": "Registrato.",
+ "current": "Peso attuale",
+ "delta": "Variazione",
+ "emptyHistory": "Nessuna rilevazione registrata. Inserisci la prima a destra: la tendenza apparirà dopo due o tre pesate."
+ },
"Plan": {
"title": "Il tuo piano",
"weekOf": "Settimana del {date}",
diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx
index 2e6596e..39aa6e4 100644
--- a/apps/web/src/app/[locale]/page.tsx
+++ b/apps/web/src/app/[locale]/page.tsx
@@ -104,6 +104,7 @@ function SignedInBlock({
diff --git a/apps/web/src/app/[locale]/tracking/actions.ts b/apps/web/src/app/[locale]/tracking/actions.ts
new file mode 100644
index 0000000..a55f692
--- /dev/null
+++ b/apps/web/src/app/[locale]/tracking/actions.ts
@@ -0,0 +1,63 @@
+'use server';
+
+import { weightEntryInputSchema, type WeightEntryInput } from '@ketopath/shared';
+import { revalidatePath } from 'next/cache';
+import { headers } from 'next/headers';
+
+const API_URL = process.env.API_URL ?? 'http://localhost:4000';
+
+export interface WeightEntryRow {
+ id: string;
+ date: string; // YYYY-MM-DD
+ weightKg: number;
+ measurements: {
+ waistCm?: number;
+ hipsCm?: number;
+ thighCm?: number;
+ armCm?: number;
+ } | null;
+ notes: string | null;
+ energy: number | null;
+ sleep: number | null;
+ hunger: number | null;
+ photos: string[];
+}
+
+function cookieHeader(): string {
+ return headers().get('cookie') ?? '';
+}
+
+export async function fetchWeightEntries(): Promise {
+ const res = await fetch(`${API_URL}/me/weight-entries`, {
+ headers: { cookie: cookieHeader() },
+ cache: 'no-store',
+ });
+ if (!res.ok) return [];
+ const data = (await res.json()) as { entries: WeightEntryRow[] };
+ return data.entries;
+}
+
+export type SaveResult = { ok: true } | { ok: false; error: string };
+
+export async function saveWeightEntry(input: WeightEntryInput): Promise {
+ const parsed = weightEntryInputSchema.safeParse(input);
+ if (!parsed.success) {
+ return { ok: false, error: parsed.error.issues[0]?.message ?? 'invalid_input' };
+ }
+
+ const res = await fetch(`${API_URL}/me/weight-entries`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', cookie: cookieHeader() },
+ body: JSON.stringify({
+ ...parsed.data,
+ date: parsed.data.date.toISOString(),
+ }),
+ cache: 'no-store',
+ });
+
+ if (!res.ok) {
+ return { ok: false, error: `api_error_${res.status}` };
+ }
+ revalidatePath('/tracking');
+ return { ok: true };
+}
diff --git a/apps/web/src/app/[locale]/tracking/page.tsx b/apps/web/src/app/[locale]/tracking/page.tsx
new file mode 100644
index 0000000..ea31554
--- /dev/null
+++ b/apps/web/src/app/[locale]/tracking/page.tsx
@@ -0,0 +1,66 @@
+import { prisma } from '@ketopath/db';
+import { redirect } from 'next/navigation';
+import { useTranslations } from 'next-intl';
+import { setRequestLocale } from 'next-intl/server';
+
+import { Masthead } from '@/components/masthead';
+import { getServerSession } from '@/lib/auth';
+
+import { fetchWeightEntries } from './actions';
+import { WeightEntryForm } from './weight-entry-form';
+import { WeightHistory } from './weight-history';
+
+export default async function TrackingPage({ params: { locale } }: { params: { locale: string } }) {
+ setRequestLocale(locale);
+ const session = await getServerSession();
+ if (!session?.user) redirect('/sign-in');
+
+ const user = await prisma.user.findUnique({
+ where: { id: session.user.id },
+ select: { disclaimerAcceptedAt: true, profile: { select: { id: true } } },
+ });
+ if (!user?.disclaimerAcceptedAt) redirect('/welcome');
+ if (!user.profile) redirect('/profile');
+
+ const entries = await fetchWeightEntries();
+
+ return ;
+}
+
+function TrackingPageContent({
+ entries,
+}: {
+ entries: Awaited>;
+}) {
+ const t = useTranslations('Tracking');
+ const latest = entries[0] ?? null;
+
+ return (
+
+
+
+ Capitolo VI
+
+ {t('title')}
+ .
+
+
+ {t('subtitle')}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/[locale]/tracking/weight-entry-form.tsx b/apps/web/src/app/[locale]/tracking/weight-entry-form.tsx
new file mode 100644
index 0000000..58c886d
--- /dev/null
+++ b/apps/web/src/app/[locale]/tracking/weight-entry-form.tsx
@@ -0,0 +1,177 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { weightEntryInputSchema, type WeightEntryInput } from '@ketopath/shared';
+import { useTranslations } from 'next-intl';
+import { useState } from 'react';
+import { useForm } from 'react-hook-form';
+
+import { Button } from '@/components/ui/button';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form';
+import { Input } from '@/components/ui/input';
+
+import { saveWeightEntry, type WeightEntryRow } from './actions';
+
+function todayISO(): string {
+ const d = new Date();
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+}
+
+export function WeightEntryForm({ latest }: { latest: WeightEntryRow | null }) {
+ const t = useTranslations('Tracking');
+ const [serverError, setServerError] = useState(null);
+ const [savedAt, setSavedAt] = useState(null);
+
+ const form = useForm({
+ resolver: zodResolver(weightEntryInputSchema),
+ defaultValues: {
+ date: todayISO(),
+ weightKg: latest?.weightKg,
+ } as WeightEntryInput,
+ });
+
+ const onSubmit = form.handleSubmit(async (input) => {
+ setServerError(null);
+ const result = await saveWeightEntry(input);
+ if (!result.ok) {
+ setServerError(result.error);
+ return;
+ }
+ setSavedAt(new Date().toISOString());
+ form.reset({ date: todayISO(), weightKg: input.weightKg } as WeightEntryInput);
+ });
+
+ return (
+
+
+ );
+}
diff --git a/apps/web/src/app/[locale]/tracking/weight-history.tsx b/apps/web/src/app/[locale]/tracking/weight-history.tsx
new file mode 100644
index 0000000..b6886f0
--- /dev/null
+++ b/apps/web/src/app/[locale]/tracking/weight-history.tsx
@@ -0,0 +1,131 @@
+import { useTranslations } from 'next-intl';
+
+import type { WeightEntryRow } from './actions';
+
+const MONTHS = ['gen', 'feb', 'mar', 'apr', 'mag', 'giu', 'lug', 'ago', 'set', 'ott', 'nov', 'dic'];
+
+function formatDate(iso: string): string {
+ const [, m, d] = iso.split('-').map((p) => parseInt(p, 10));
+ return `${d} ${MONTHS[(m ?? 1) - 1]}`;
+}
+
+interface SparkProps {
+ values: number[];
+ width?: number;
+ height?: number;
+}
+
+// Tiny inline sparkline. Pure SVG, no client deps.
+function Sparkline({ values, width = 280, height = 64 }: SparkProps) {
+ if (values.length < 2) return null;
+ const min = Math.min(...values);
+ const max = Math.max(...values);
+ const span = Math.max(max - min, 0.1);
+ const stepX = width / (values.length - 1);
+ const points = values
+ .map((v, i) => {
+ const x = i * stepX;
+ const y = height - ((v - min) / span) * (height - 8) - 4;
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
+ })
+ .join(' ');
+
+ return (
+
+ );
+}
+
+export function WeightHistory({ entries }: { entries: WeightEntryRow[] }) {
+ const t = useTranslations('Tracking');
+ if (entries.length === 0) {
+ return (
+
+ {t('emptyHistory')}
+
+ );
+ }
+
+ // entries arrive newest-first; reverse for the trend, keep newest-first for the list.
+ const trendValues = [...entries].reverse().map((e) => e.weightKg);
+ const newest = entries[0]!;
+ const oldest = entries[entries.length - 1]!;
+ const delta = newest.weightKg - oldest.weightKg;
+
+ return (
+
+
+
+
+ {formatDate(oldest.date)}
+ {formatDate(newest.date)}
+
+
+
+
+
+
{t('current')}
+
+ {newest.weightKg.toFixed(1)}
+ kg
+
+
+
+
{t('delta')}
+
0 ? 'text-pomodoro' : 'text-ink'
+ }`}
+ >
+ {delta > 0 ? '+' : ''}
+ {delta.toFixed(1)}
+ kg
+
+
+
+
+
+
+ );
+}