From 4e00c131b94df0b357839c44cf0e26a251c2c874 Mon Sep 17 00:00:00 2001 From: luciano Date: Wed, 29 Apr 2026 16:53:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20tracking=20page=20=E2=80=94=20weig?= =?UTF-8?q?ht=20entries=20with=20sparkline=20+=20history=20(PRD=20=C2=A75.?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New /tracking route in the editorial language: - "CAPITOLO VI / Pesata e misure." masthead - 7/5 split: form left, STORICO right Form (left, "NUOVA RILEVAZIONE"): - Date + weight required, with the latest weight pre-filled to encourage consistency between weeks - Optional misure: girovita / fianchi / coscia / braccio (cm), grouped under a "MISURE (CM) — OPZIONALI" eyebrow - Subjective indicators 1–10: energia / sonno / fame - Italic "Registrato." (oliva) confirmation after a successful POST - Server action calls POST /me/weight-entries through API_URL with the user's session cookie; sparkline + history revalidate via revalidatePath('/tracking') History (right, "STORICO"): - Inline SVG sparkline of the user's weight series with a terracotta dot marking the latest reading; ink-soft date axis labels under the line - Two big mono stats: "PESO ATTUALE" and "VARIAZIONE" (oliva when negative, pomodoro when positive, ink at zero) - Newest 8 entries listed with hairline rules between rows - Italian italic empty-state copy when no entries exist yet Home gains a third NavItem ("Tracking — Pesata & misure"). Verified manually with a fresh user signup → disclaimer → profile → tracking: sparkline / "PESO ATTUALE 76.0 kg" / "VARIAZIONE 0.0 kg" / list row "29 apr 76.0 kg". Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/messages/it.json | 24 +++ apps/web/src/app/[locale]/page.tsx | 1 + apps/web/src/app/[locale]/tracking/actions.ts | 63 +++++++ apps/web/src/app/[locale]/tracking/page.tsx | 66 +++++++ .../[locale]/tracking/weight-entry-form.tsx | 177 ++++++++++++++++++ .../app/[locale]/tracking/weight-history.tsx | 131 +++++++++++++ 6 files changed, 462 insertions(+) create mode 100644 apps/web/src/app/[locale]/tracking/actions.ts create mode 100644 apps/web/src/app/[locale]/tracking/page.tsx create mode 100644 apps/web/src/app/[locale]/tracking/weight-entry-form.tsx create mode 100644 apps/web/src/app/[locale]/tracking/weight-history.tsx 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')} +

+ +
+ +
+
+

{t('newEntry')}

+ +
+ +
+
+
+ ); +} 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 ( +
+ +
+ ( + + {t('date')} + + + + + + )} + /> + ( + + {t('weightKg')} + + + + + + )} + /> +
+ +
+

{t('measurements')}

+
+ {( + [ + ['measurements.waistCm', t('waist')], + ['measurements.hipsCm', t('hips')], + ['measurements.thighCm', t('thigh')], + ['measurements.armCm', t('arm')], + ] as const + ).map(([name, label]) => ( + ( + + {label} + + + + + + )} + /> + ))} +
+
+ +
+

{t('subjective')}

+
+ {(['energy', 'sleep', 'hunger'] as const).map((name) => ( + ( + + {t(name)} + + + + + + )} + /> + ))} +
+
+ + {serverError ? ( +

+ {serverError} +

+ ) : null} + {savedAt && !serverError ? ( +

{t('saved')}

+ ) : null} + +
+ +
+
+ + ); +} 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 +

+
+
+ +
    + {entries.slice(0, 8).map((e) => ( +
  • +
    +

    {formatDate(e.date)}

    + {e.notes ? ( +

    + {e.notes} +

    + ) : null} +
    +

    + {e.weightKg.toFixed(1)} + kg +

    +
  • + ))} +
+
+ ); +}