Files
ketopath/apps/web/src/app/[locale]/tracking/daily-check-in.tsx
T
lucianoandClaude Opus 4.7 9327e75395 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>
2026-04-29 23:50:05 +02:00

90 lines
2.9 KiB
TypeScript

'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>
);
}