feat(web): tracking page — weight entries with sparkline + history (PRD §5.2)
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
59a311cfc9
commit
4e00c131b9
6 files changed
+462
No files matched your search
@@ -9,6 +9,7 @@
|
|||||||
"signOut": "Esci",
|
"signOut": "Esci",
|
||||||
"completeProfile": "Profilo & calcoli",
|
"completeProfile": "Profilo & calcoli",
|
||||||
"viewPlan": "Piano della settimana",
|
"viewPlan": "Piano della settimana",
|
||||||
|
"viewTracking": "Pesata & misure",
|
||||||
"lettura": "Continua la lettura",
|
"lettura": "Continua la lettura",
|
||||||
"inizia": "Inizia",
|
"inizia": "Inizia",
|
||||||
"greeting": "Buongiorno, <name>{name}</name>.",
|
"greeting": "Buongiorno, <name>{name}</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."
|
"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": {
|
"Plan": {
|
||||||
"title": "Il tuo piano",
|
"title": "Il tuo piano",
|
||||||
"weekOf": "Settimana del {date}",
|
"weekOf": "Settimana del {date}",
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ function SignedInBlock({
|
|||||||
</div>
|
</div>
|
||||||
<ul className="space-y-px">
|
<ul className="space-y-px">
|
||||||
<NavItem href="/plan" label={t('viewPlan')} eyebrow="Cucina" />
|
<NavItem href="/plan" label={t('viewPlan')} eyebrow="Cucina" />
|
||||||
|
<NavItem href="/tracking" label={t('viewTracking')} eyebrow="Tracking" />
|
||||||
<NavItem href="/profile" label={t('completeProfile')} eyebrow="Profilo" />
|
<NavItem href="/profile" label={t('completeProfile')} eyebrow="Profilo" />
|
||||||
</ul>
|
</ul>
|
||||||
<SignOutButton label={t('signOut')} />
|
<SignOutButton label={t('signOut')} />
|
||||||
|
|||||||
@@ -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<WeightEntryRow[]> {
|
||||||
|
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<SaveResult> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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 <TrackingPageContent entries={entries} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrackingPageContent({
|
||||||
|
entries,
|
||||||
|
}: {
|
||||||
|
entries: Awaited<ReturnType<typeof fetchWeightEntries>>;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations('Tracking');
|
||||||
|
const latest = entries[0] ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto min-h-screen max-w-5xl px-6 sm:px-10">
|
||||||
|
<Masthead issueLabel="N. 06 — Tracking" />
|
||||||
|
<main className="py-12 sm:py-16">
|
||||||
|
<p className="editorial-eyebrow animate-fade-up">Capitolo VI</p>
|
||||||
|
<h1 className="font-display text-display text-ink animate-fade-up mt-3 font-medium leading-[0.95] tracking-tight [animation-delay:120ms]">
|
||||||
|
{t('title')}
|
||||||
|
<span className="text-pomodoro">.</span>
|
||||||
|
</h1>
|
||||||
|
<p className="font-display text-ink-soft animate-fade-up mt-6 max-w-2xl text-xl italic leading-snug [animation-delay:240ms]">
|
||||||
|
{t('subtitle')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="rule animate-rule-in my-10 [animation-delay:360ms]" />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<WeightEntryForm latest={latest} />
|
||||||
|
</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} />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(null);
|
||||||
|
const [savedAt, setSavedAt] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const form = useForm<WeightEntryInput>({
|
||||||
|
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 (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={onSubmit} className="space-y-10" noValidate>
|
||||||
|
<div className="grid grid-cols-2 gap-x-8 gap-y-8">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="date"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="space-y-3">
|
||||||
|
<FormLabel>{t('date')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="date" {...field} value={field.value as string} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="weightKg"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="space-y-3">
|
||||||
|
<FormLabel>{t('weightKg')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
inputMode="decimal"
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ''}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<p className="editorial-eyebrow">{t('measurements')}</p>
|
||||||
|
<div className="grid grid-cols-2 gap-x-6 gap-y-8 sm:grid-cols-4">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['measurements.waistCm', t('waist')],
|
||||||
|
['measurements.hipsCm', t('hips')],
|
||||||
|
['measurements.thighCm', t('thigh')],
|
||||||
|
['measurements.armCm', t('arm')],
|
||||||
|
] as const
|
||||||
|
).map(([name, label]) => (
|
||||||
|
<FormField
|
||||||
|
key={name}
|
||||||
|
control={form.control}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
name={name as any}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="space-y-3">
|
||||||
|
<FormLabel>{label}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
inputMode="decimal"
|
||||||
|
{...field}
|
||||||
|
value={(field.value as number | undefined) ?? ''}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<p className="editorial-eyebrow">{t('subjective')}</p>
|
||||||
|
<div className="grid grid-cols-3 gap-x-6 gap-y-8">
|
||||||
|
{(['energy', 'sleep', 'hunger'] as const).map((name) => (
|
||||||
|
<FormField
|
||||||
|
key={name}
|
||||||
|
control={form.control}
|
||||||
|
name={name}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="space-y-3">
|
||||||
|
<FormLabel>{t(name)}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="1–10"
|
||||||
|
{...field}
|
||||||
|
value={(field.value as number | undefined) ?? ''}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{serverError ? (
|
||||||
|
<p role="alert" className="font-display text-pomodoro text-base italic">
|
||||||
|
{serverError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{savedAt && !serverError ? (
|
||||||
|
<p className="font-display text-oliva text-base italic">{t('saved')}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
size="lg"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{form.formState.isSubmitting ? t('saving') : t('save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
width="100%"
|
||||||
|
height={height}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
role="img"
|
||||||
|
aria-label="Trend peso"
|
||||||
|
>
|
||||||
|
<polyline
|
||||||
|
fill="none"
|
||||||
|
stroke="hsl(var(--ink))"
|
||||||
|
strokeWidth="1.25"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
points={points}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={(values.length - 1) * stepX}
|
||||||
|
cy={height - ((values[values.length - 1]! - min) / span) * (height - 8) - 4}
|
||||||
|
r="3.5"
|
||||||
|
fill="hsl(var(--pomodoro))"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WeightHistory({ entries }: { entries: WeightEntryRow[] }) {
|
||||||
|
const t = useTranslations('Tracking');
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="font-display text-ink-dim text-base italic leading-relaxed">
|
||||||
|
{t('emptyHistory')}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<Sparkline values={trendValues} />
|
||||||
|
<div className="text-ink-dim mt-2 flex items-baseline justify-between font-mono text-[10px] uppercase tracking-widest">
|
||||||
|
<span>{formatDate(oldest.date)}</span>
|
||||||
|
<span>{formatDate(newest.date)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-ink/10 grid grid-cols-2 gap-6 border-y py-5">
|
||||||
|
<div>
|
||||||
|
<p className="editorial-eyebrow">{t('current')}</p>
|
||||||
|
<p className="text-ink mt-2 font-mono text-3xl font-medium leading-none tracking-tight">
|
||||||
|
{newest.weightKg.toFixed(1)}
|
||||||
|
<span className="font-display text-ink-soft ml-1 text-xs italic">kg</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="editorial-eyebrow">{t('delta')}</p>
|
||||||
|
<p
|
||||||
|
className={`mt-2 font-mono text-3xl font-medium leading-none tracking-tight ${
|
||||||
|
delta < 0 ? 'text-oliva' : delta > 0 ? 'text-pomodoro' : 'text-ink'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{delta > 0 ? '+' : ''}
|
||||||
|
{delta.toFixed(1)}
|
||||||
|
<span className="font-display text-ink-soft ml-1 text-xs italic">kg</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{entries.slice(0, 8).map((e) => (
|
||||||
|
<li
|
||||||
|
key={e.id}
|
||||||
|
className="border-ink/10 flex items-baseline justify-between gap-4 border-b pb-2 last:border-b-0"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-display text-ink text-base leading-tight">{formatDate(e.date)}</p>
|
||||||
|
{e.notes ? (
|
||||||
|
<p className="font-display text-ink-dim mt-1 line-clamp-1 text-xs italic">
|
||||||
|
{e.notes}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="text-ink font-mono text-sm tabular-nums">
|
||||||
|
{e.weightKg.toFixed(1)}
|
||||||
|
<span className="font-display text-ink-dim ml-1 text-[10px] italic">kg</span>
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user