feat: §5.3 completion — stats, symptoms, milestones, pause, break-fast

Cinque feature che chiudono il PRD §5.3 (gestione digiuno).

§5.3.1 Streak + statistiche settimanali/mensili
- @ketopath/shared/tracking/fasting-stats: computeFastingStats(events) ritorna
  weeklyHours, monthlyHours, currentStreak, longestStreak, completionRate,
  totalSessions, completedSessions
- 6 unit test (currentStreak consecutivo, longestStreak storico, completionRate
  ignora IN_PROGRESS, range temporali)
- /fasting: la striscia di stat passa da 3 a 5 (settimanali, mensili, streak
  attuale, streak record, % completamento)

§5.3.2 Diario sintomi durante il digiuno
- updateFastSymptoms server action → PATCH /me/fast-events/:id { symptoms }
- SymptomsDiary component nel timer attivo: checkbox "Mal di testa" + 3 slider
  1-10 (Energia, Fame, Lucidità) + textarea note libere; "Salva" non
  interrompe il timer

§5.3.3 Notifiche per-fast
- FastEvent.notifiedMilestones String[] (idempotenza)
- Cron */5 * * * * `runFastingMilestonesJob` scansiona le sessioni IN_PROGRESS
  e invia push secondo le milestone:
  · hydration_2h / 4h / 6h — promemoria idratazione
  · electrolyte_18h — alert elettroliti per digiuni lunghi
  · ending_soon — 30 min prima della fine
- Skip automatico se l'utente ha fastingPausedUntil futuro
- Skip se l'utente non ha attivato `fastingMilestones` nelle preferenze
- Server.ts gestisce ora 2 ScheduledTask (weekly + fasting)

§5.3.4 Modalità giorno libero
- User.fastingPausedUntil DateTime?
- POST /me/fasting/pause con body { until?: ISO|null } — default: domani 06:00
- GET /me/fasting/pause ritorna { fastingPausedUntil, paused }
- /fasting: FastingPauseToggle bottone "Pausa per oggi" → banner con countdown +
  "Riprendi adesso" per terminare prima

§5.3.5 Pasto di rottura intelligente
- GET /me/fast-events/:id/break-fast-suggestions: solo per digiuni >= 24h,
  ritorna 3 ricette con prepMinutes ≤ 20, kcal ≤ 350, categoria
  COLAZIONE/SPUNTINO. Heuristica per "facile da digerire".
- /fasting history: per le sessioni completate >= 24h aggiunge un disclosure
  "Suggerimenti per riprendere a mangiare" che fetcha on-demand e linka alle
  ricette

Schema migrations: add_fasting_paused_until, add_fast_event_milestones.

72/72 unit test verdi (6 nuovi). Lint, typecheck, build verdi.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.7 committed 2026-04-29 23:41:20 +02:00
1 parent c37dc5d717
commit ea88ed8775
16 files changed
+810 -54

No files matched your search

+24
View File
@@ -136,6 +136,30 @@
"statCompleted": "Sessioni concluse",
"statTotal": "Ore totali",
"statSessions": "Sessioni totali",
"statWeekly": "Ore (7 gg)",
"statMonthly": "Ore (30 gg)",
"statCurrentStreak": "Streak attuale",
"statLongestStreak": "Streak record",
"statCompletionRate": "Tasso completamento",
"statStreakValue": "{n, plural, =0 {0 giorni} one {# giorno} other {# giorni}}",
"symptomsTitle": "Come ti senti?",
"symptomsHint": "Aggiorna i sintomi: ti aiuta a capire come reagisci a questo protocollo.",
"symptomHeadache": "Mal di testa",
"symptomEnergy": "Energia",
"symptomHunger": "Fame",
"symptomClarity": "Lucidità",
"symptomOther": "Altre note",
"symptomOtherPlaceholder": "Note libere…",
"symptomsSave": "Salva sintomi",
"symptomsSaved": "Salvato.",
"pauseEyebrow": "Modalità giorno libero",
"pauseUntil": "Reminder in pausa fino a {time}.",
"pauseToday": "Pausa per oggi",
"pauseResume": "Riprendi adesso",
"breakFastShow": "Suggerimenti per riprendere a mangiare",
"breakFastHide": "Nascondi suggerimenti",
"breakFastEmpty": "Nessuna ricetta disponibile in questo momento.",
"working": "Attendere…",
"status": {
"IN_PROGRESS": "In corso",
"COMPLETED": "Completata",
@@ -25,6 +25,56 @@ function cookieHeader(): string {
return headers().get('cookie') ?? '';
}
export interface BreakFastSuggestion {
id: string;
name: string;
kcal: number;
proteinG: number;
prepMinutes: number;
description: string | null;
}
export async function fetchBreakFastSuggestions(
fastEventId: string,
): Promise<BreakFastSuggestion[]> {
const res = await fetch(`${API_URL}/me/fast-events/${fastEventId}/break-fast-suggestions`, {
headers: { cookie: cookieHeader() },
cache: 'no-store',
});
if (!res.ok) return [];
const data = (await res.json()) as { suggestions: BreakFastSuggestion[] };
return data.suggestions;
}
export interface FastingPauseStatus {
fastingPausedUntil: string | null;
paused: boolean;
}
export async function fetchFastingPause(): Promise<FastingPauseStatus> {
const res = await fetch(`${API_URL}/me/fasting/pause`, {
headers: { cookie: cookieHeader() },
cache: 'no-store',
});
if (!res.ok) return { fastingPausedUntil: null, paused: false };
return (await res.json()) as FastingPauseStatus;
}
export async function setFastingPause(
until: Date | null,
): Promise<{ ok: true } | { ok: false; error: string }> {
const body = until === null ? { until: null } : { until: until.toISOString() };
const res = await fetch(`${API_URL}/me/fasting/pause`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', cookie: cookieHeader() },
body: JSON.stringify(body),
cache: 'no-store',
});
if (!res.ok) return { ok: false, error: `api_error_${res.status}` };
revalidatePath('/fasting');
return { ok: true };
}
export async function fetchFastEvents(): Promise<FastEventRow[]> {
const res = await fetch(`${API_URL}/me/fast-events`, {
headers: { cookie: cookieHeader() },
@@ -62,6 +112,32 @@ export async function startFast(input: FastEventStartInput): Promise<StartResult
return { ok: true, id: data.event.id };
}
export interface FastSymptomsInput {
headache?: boolean;
energy?: number;
hunger?: number;
clarity?: number;
other?: string;
}
export async function updateFastSymptoms(
id: string,
symptoms: FastSymptomsInput,
): Promise<{ ok: true } | { ok: false; error: string }> {
const res = await fetch(`${API_URL}/me/fast-events/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', cookie: cookieHeader() },
body: JSON.stringify({ symptoms }),
cache: 'no-store',
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
return { ok: false, error: body.error ?? `api_error_${res.status}` };
}
revalidatePath('/fasting');
return { ok: true };
}
export type EndResult = { ok: true } | { ok: false; error: string };
export async function endFast(id: string, abort: boolean = false): Promise<EndResult> {
@@ -0,0 +1,64 @@
'use client';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { useState, useTransition } from 'react';
import { fetchBreakFastSuggestions, type BreakFastSuggestion } from './actions';
export function BreakFastSuggestionsBlock({ fastEventId }: { fastEventId: string }) {
const t = useTranslations('Fasting');
const [open, setOpen] = useState(false);
const [items, setItems] = useState<BreakFastSuggestion[] | null>(null);
const [pending, startTransition] = useTransition();
function toggle(): void {
if (!items && !pending) {
startTransition(async () => {
const list = await fetchBreakFastSuggestions(fastEventId);
setItems(list);
setOpen(true);
});
} else {
setOpen((v) => !v);
}
}
return (
<div className="pl-7">
<button
type="button"
onClick={toggle}
className="text-ink-soft decoration-pomodoro hover:text-ink font-mono text-[10px] uppercase tracking-widest underline decoration-[1.5px] underline-offset-[5px] transition-colors"
>
{pending ? t('working') : open ? t('breakFastHide') : t('breakFastShow')}
</button>
{open && items ? (
<div className="mt-3">
{items.length === 0 ? (
<p className="font-display text-ink-dim text-sm italic">{t('breakFastEmpty')}</p>
) : (
<ul className="space-y-2">
{items.map((s) => (
<li
key={s.id}
className="border-ink/10 flex items-baseline justify-between gap-3 border-b pb-2"
>
<Link
href={`/recipes/${s.id}`}
className="font-display text-ink hover:text-pomodoro text-sm leading-snug transition-colors"
>
{s.name}
</Link>
<span className="text-ink-dim font-mono text-[10px] uppercase tracking-widest">
{Math.round(s.kcal)} kcal · {s.prepMinutes} min
</span>
</li>
))}
</ul>
)}
</div>
) : null}
</div>
);
}
@@ -1,6 +1,8 @@
import { computeFastingStats } from '@ketopath/shared';
import { useTranslations } from 'next-intl';
import type { FastEventRow } from './actions';
import { BreakFastSuggestionsBlock } from './break-fast-suggestions';
const PROTOCOL_LABEL: Record<FastEventRow['protocol'], string> = {
FOURTEEN_TEN: '14:10',
@@ -44,43 +46,54 @@ export function FastHistory({ events }: { events: FastEventRow[] }) {
);
}
const completed = events.filter((e) => e.status === 'COMPLETED');
const totalHours =
completed.reduce(
(sum, e) =>
sum + ((e.endedAt ? new Date(e.endedAt).getTime() : 0) - new Date(e.startedAt).getTime()),
0,
) / 3_600_000;
const stats = computeFastingStats(events);
return (
<div className="space-y-8">
<div className="border-ink/10 grid grid-cols-2 gap-6 border-y py-5 sm:grid-cols-3">
<Stat label={t('statCompleted')} value={String(completed.length)} />
<Stat label={t('statTotal')} value={`${totalHours.toFixed(1)} h`} />
<Stat label={t('statSessions')} value={String(events.length)} />
<div className="border-ink/10 grid grid-cols-2 gap-6 border-y py-5 sm:grid-cols-5">
<Stat label={t('statWeekly')} value={`${stats.weeklyHours.toFixed(1)} h`} />
<Stat label={t('statMonthly')} value={`${stats.monthlyHours.toFixed(1)} h`} />
<Stat
label={t('statCurrentStreak')}
value={t('statStreakValue', { n: stats.currentStreak })}
/>
<Stat
label={t('statLongestStreak')}
value={t('statStreakValue', { n: stats.longestStreak })}
/>
<Stat
label={t('statCompletionRate')}
value={`${Math.round(stats.completionRate * 100)}%`}
/>
</div>
<ul className="space-y-3">
{events.slice(0, 12).map((e) => (
<li
key={e.id}
className="border-ink/10 grid grid-cols-[auto_1fr_auto] items-baseline gap-4 border-b pb-3 last:border-b-0"
>
<span className="font-display text-pomodoro text-base italic tabular-nums">
{PROTOCOL_LABEL[e.protocol]}
</span>
<div>
<p className="font-display text-ink text-base leading-tight">
{formatDate(e.startedAt)}
</p>
<p className="text-ink-dim mt-0.5 font-mono text-[10px] uppercase tracking-widest">
{t(`status.${e.status}`)}
</p>
</div>
<p className="text-ink font-mono text-sm tabular-nums">
{durationLabel(e.startedAt, e.endedAt)}
</p>
</li>
))}
{events.slice(0, 12).map((e) => {
const hours = e.endedAt
? (new Date(e.endedAt).getTime() - new Date(e.startedAt).getTime()) / 3_600_000
: 0;
const isLongFast = e.status === 'COMPLETED' && hours >= 24;
return (
<li key={e.id} className="border-ink/10 space-y-3 border-b pb-3 last:border-b-0">
<div className="grid grid-cols-[auto_1fr_auto] items-baseline gap-4">
<span className="font-display text-pomodoro text-base italic tabular-nums">
{PROTOCOL_LABEL[e.protocol]}
</span>
<div>
<p className="font-display text-ink text-base leading-tight">
{formatDate(e.startedAt)}
</p>
<p className="text-ink-dim mt-0.5 font-mono text-[10px] uppercase tracking-widest">
{t(`status.${e.status}`)}
</p>
</div>
<p className="text-ink font-mono text-sm tabular-nums">
{durationLabel(e.startedAt, e.endedAt)}
</p>
</div>
{isLongFast ? <BreakFastSuggestionsBlock fastEventId={e.id} /> : null}
</li>
);
})}
</ul>
</div>
);
+122 -13
View File
@@ -13,7 +13,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { endFast, startFast, type FastEventRow } from './actions';
import { endFast, startFast, updateFastSymptoms, type FastEventRow } from './actions';
type Protocol = (typeof FASTING_PROTOCOLS)[number];
@@ -107,18 +107,21 @@ function ActiveTimer({ event }: { event: FastEventRow }) {
</Button>
</div>
</div>
<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">{t('phaseTitle')}</p>
<p className="font-display text-pomodoro mt-4 text-2xl font-medium leading-tight">
{t(`phases.${phase}.label`)}
</p>
<p className="font-display text-ink-soft mt-3 text-base italic leading-snug">
{t(`phases.${phase}.description`)}
</p>
<dl className="mt-8 space-y-5 text-sm">
<Row label={t('startedAt')} value={formatStart(event.startedAt)} />
<Row label={t('target')} value={`${(event.targetDuration / 60).toFixed(1)} h`} />
</dl>
<aside className="md:border-ink/15 animate-fade-up space-y-10 [animation-delay:420ms] md:col-span-5 md:border-l md:pl-10">
<div>
<p className="editorial-eyebrow">{t('phaseTitle')}</p>
<p className="font-display text-pomodoro mt-4 text-2xl font-medium leading-tight">
{t(`phases.${phase}.label`)}
</p>
<p className="font-display text-ink-soft mt-3 text-base italic leading-snug">
{t(`phases.${phase}.description`)}
</p>
<dl className="mt-8 space-y-5 text-sm">
<Row label={t('startedAt')} value={formatStart(event.startedAt)} />
<Row label={t('target')} value={`${(event.targetDuration / 60).toFixed(1)} h`} />
</dl>
</div>
<SymptomsDiary event={event} />
</aside>
</section>
);
@@ -231,3 +234,109 @@ const HOUR_FMT = new Intl.DateTimeFormat('it-IT', {
function formatStart(iso: string): string {
return HOUR_FMT.format(new Date(iso));
}
function SymptomsDiary({ event }: { event: FastEventRow }) {
const t = useTranslations('Fasting');
const initial = (event.symptoms as Record<string, unknown> | null) ?? {};
const [headache, setHeadache] = useState<boolean>(!!initial.headache);
const [energy, setEnergy] = useState<number>(
typeof initial.energy === 'number' ? initial.energy : 5,
);
const [hunger, setHunger] = useState<number>(
typeof initial.hunger === 'number' ? initial.hunger : 5,
);
const [clarity, setClarity] = useState<number>(
typeof initial.clarity === 'number' ? initial.clarity : 5,
);
const [other, setOther] = useState<string>(
typeof initial.other === 'string' ? initial.other : '',
);
const [pending, startTransition] = useTransition();
const [saved, setSaved] = useState<boolean>(false);
function save(): void {
startTransition(async () => {
setSaved(false);
const result = await updateFastSymptoms(event.id, {
headache,
energy,
hunger,
clarity,
other: other.trim() || undefined,
});
if (result.ok) {
setSaved(true);
window.setTimeout(() => setSaved(false), 2500);
}
});
}
return (
<div className="space-y-5">
<p className="editorial-eyebrow">{t('symptomsTitle')}</p>
<p className="font-display text-ink-soft text-sm italic leading-snug">{t('symptomsHint')}</p>
<label className="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
checked={headache}
onChange={(e) => setHeadache(e.target.checked)}
className="border-ink h-4 w-4 cursor-pointer accent-current"
/>
<span className="font-display text-ink text-base leading-tight">
{t('symptomHeadache')}
</span>
</label>
<SymptomSlider label={t('symptomEnergy')} value={energy} onChange={setEnergy} />
<SymptomSlider label={t('symptomHunger')} value={hunger} onChange={setHunger} />
<SymptomSlider label={t('symptomClarity')} value={clarity} onChange={setClarity} />
<div>
<p className="editorial-eyebrow mb-2">{t('symptomOther')}</p>
<textarea
value={other}
onChange={(e) => setOther(e.target.value)}
rows={2}
maxLength={500}
placeholder={t('symptomOtherPlaceholder')}
className="border-ink/30 focus:border-ink font-display text-ink w-full resize-none border-b bg-transparent py-2 text-sm leading-snug outline-none"
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button type="button" variant="outline" onClick={save} disabled={pending}>
{pending ? t('saving') : t('symptomsSave')}
</Button>
{saved ? (
<span className="font-display text-oliva text-sm italic" role="status">
{t('symptomsSaved')}
</span>
) : null}
</div>
</div>
);
}
function SymptomSlider({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (n: number) => void;
}) {
return (
<div>
<div className="flex items-baseline justify-between gap-3">
<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>
);
}
@@ -0,0 +1,73 @@
'use client';
import { useTranslations } from 'next-intl';
import { useState, useTransition } from 'react';
import { Button } from '@/components/ui/button';
import { setFastingPause, type FastingPauseStatus } from './actions';
const FORMATTER = new Intl.DateTimeFormat('it-IT', {
weekday: 'long',
hour: '2-digit',
minute: '2-digit',
});
export function FastingPauseToggle({ initial }: { initial: FastingPauseStatus }) {
const t = useTranslations('Fasting');
const [paused, setPaused] = useState<boolean>(initial.paused);
const [until, setUntil] = useState<string | null>(initial.fastingPausedUntil);
const [pending, startTransition] = useTransition();
function pause(): void {
startTransition(async () => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(6, 0, 0, 0);
const result = await setFastingPause(tomorrow);
if (result.ok) {
setPaused(true);
setUntil(tomorrow.toISOString());
}
});
}
function resume(): void {
startTransition(async () => {
const result = await setFastingPause(null);
if (result.ok) {
setPaused(false);
setUntil(null);
}
});
}
if (paused && until) {
return (
<aside className="border-pomodoro/30 bg-pomodoro/5 mb-10 flex flex-wrap items-baseline justify-between gap-4 border-2 border-dashed p-5">
<div>
<p className="editorial-eyebrow">{t('pauseEyebrow')}</p>
<p className="font-display text-ink mt-2 text-base leading-snug">
{t('pauseUntil', { time: FORMATTER.format(new Date(until)) })}
</p>
</div>
<Button type="button" variant="outline" onClick={resume} disabled={pending}>
{pending ? t('saving') : t('pauseResume')}
</Button>
</aside>
);
}
return (
<div className="mb-10 flex justify-end">
<button
type="button"
onClick={pause}
disabled={pending}
className="text-ink-soft decoration-pomodoro hover:text-ink font-mono text-[11px] uppercase tracking-widest underline decoration-[1.5px] underline-offset-[5px] transition-colors disabled:opacity-40"
>
{pending ? t('saving') : t('pauseToday')}
</button>
</div>
);
}
+8 -3
View File
@@ -6,9 +6,10 @@ import { setRequestLocale } from 'next-intl/server';
import { Masthead } from '@/components/masthead';
import { getServerSession } from '@/lib/auth';
import { fetchFastEvents } from './actions';
import { fetchFastEvents, fetchFastingPause, type FastingPauseStatus } from './actions';
import { FastHistory } from './fast-history';
import { FastTimer } from './fast-timer';
import { FastingPauseToggle } from './fasting-pause-toggle';
export default async function FastingPage({ params: { locale } }: { params: { locale: string } }) {
setRequestLocale(locale);
@@ -21,19 +22,21 @@ export default async function FastingPage({ params: { locale } }: { params: { lo
});
if (!user?.disclaimerAcceptedAt) redirect('/welcome');
const events = await fetchFastEvents();
const [events, pauseStatus] = await Promise.all([fetchFastEvents(), fetchFastingPause()]);
const active = events.find((e) => e.status === 'IN_PROGRESS') ?? null;
const history = events.filter((e) => e.id !== active?.id);
return <FastingPageContent active={active} history={history} />;
return <FastingPageContent active={active} history={history} pauseStatus={pauseStatus} />;
}
function FastingPageContent({
active,
history,
pauseStatus,
}: {
active: Awaited<ReturnType<typeof fetchFastEvents>>[number] | null;
history: Awaited<ReturnType<typeof fetchFastEvents>>;
pauseStatus: FastingPauseStatus;
}) {
const t = useTranslations('Fasting');
@@ -52,6 +55,8 @@ function FastingPageContent({
<div className="rule animate-rule-in my-10 [animation-delay:360ms]" />
<FastingPauseToggle initial={pauseStatus} />
<FastTimer active={active} />
<div className="animate-fade-up mt-16 [animation-delay:540ms]">