From b79340efb71d6165f973850be05ba5776be6d9a9 Mon Sep 17 00:00:00 2001 From: luciano Date: Wed, 29 Apr 2026 17:15:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20fasting=20page=20=E2=80=94=20live?= =?UTF-8?q?=20timer,=20metabolic=20phase,=20history=20(PRD=20=C2=A75.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New /fasting route. Editorial layout matching the rest of the app ("CAPITOLO VII / Digiuno intermittente."). When no active session: - "Pronto a iniziare" eyebrow + italic call-to-action - Protocol Select (16:8 default; full set FASTING_PROTOCOLS, hours derived from PROTOCOL_DEFAULT_MINUTES) - "Avvia digiuno" CTA → server action that POSTs to /me/fast-events - Right column: Roman-numeral guide (I-IV) for the four most common protocols with title + description from messages.json When a session is active (status=IN_PROGRESS, no endedAt): - Live timer in massive monospace clamp(3rem,9vw,6.25rem), tabular numerals, ticking once per second via setInterval - Italic remaining-time line ("Mancano HH:MM:SS") or "Obiettivo raggiunto." once the protocol target is reached - Hairline progress bar; ink while running, pomodoro after target - "Concludi adesso" / "Concludi" CTA → PATCH with status=COMPLETED - "Annulla sessione" ghost link → PATCH with status=ABORTED (with confirm()) - Right column: "FASE METABOLICA" with current phase title in pomodoro and italic description (postprandial 0-4h / glycogenolysis 4-12h / lipolysis 12-18h / ketogenesis 18-24h / autophagy 24h+, PRD §5.3) - "INIZIATO" and "OBIETTIVO" stats in mono History below: - Three big mono stats: completed sessions count, total hours, total sessions - Last 12 events listed with protocol label in pomodoro italic, italian short date, status eyebrow, duration in mono Home gains a fourth NavItem ("Digiuno — Digiuno intermittente"). Verified end-to-end in Chrome: started a 16:8 → live ticking timer at 00:00:04, "Mancano 15:59:55", "Post-prandiale" phase shown. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/messages/it.json | 72 ++++++ apps/web/src/app/[locale]/fasting/actions.ts | 83 +++++++ .../src/app/[locale]/fasting/fast-history.tsx | 98 ++++++++ .../src/app/[locale]/fasting/fast-timer.tsx | 233 ++++++++++++++++++ apps/web/src/app/[locale]/fasting/page.tsx | 64 +++++ apps/web/src/app/[locale]/page.tsx | 1 + 6 files changed, 551 insertions(+) create mode 100644 apps/web/src/app/[locale]/fasting/actions.ts create mode 100644 apps/web/src/app/[locale]/fasting/fast-history.tsx create mode 100644 apps/web/src/app/[locale]/fasting/fast-timer.tsx create mode 100644 apps/web/src/app/[locale]/fasting/page.tsx diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index 583b64c..ae989a2 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -10,6 +10,7 @@ "completeProfile": "Profilo & calcoli", "viewPlan": "Piano della settimana", "viewTracking": "Pesata & misure", + "viewFasting": "Digiuno intermittente", "lettura": "Continua la lettura", "inizia": "Inizia", "greeting": "Buongiorno, {name}.", @@ -69,6 +70,77 @@ "disclaimer": "Procedendo accetti che i tuoi dati siano trattati come descritto nell'informativa sulla privacy. KetoPath non sostituisce il parere medico." } }, + "Fasting": { + "title": "Digiuno intermittente", + "subtitle": "Una finestra alimentare e una di riposo. Il timer scandisce il ritmo, il corpo fa il resto.", + "historyTitle": "Storico sessioni", + "activeBadge": "Sessione in corso", + "inactiveBadge": "Pronto a iniziare", + "startCallToAction": "Scegli un protocollo e parti. Il timer continua a scorrere anche se chiudi l'app.", + "chooseProtocol": "Protocollo", + "start": "Avvia digiuno", + "starting": "Avvio…", + "saving": "Salvataggio…", + "remaining": "Mancano {time}", + "reached": "Obiettivo raggiunto. Puoi rompere il digiuno o proseguire.", + "completeReached": "Concludi", + "completeEarly": "Concludi adesso", + "abort": "Annulla sessione", + "confirmAbort": "Sei sicuro di voler annullare questa sessione?", + "phaseTitle": "Fase metabolica", + "phases": { + "postprandial": { + "label": "Post-prandiale", + "description": "Digestione in corso. Insulina alta, glicemia in salita." + }, + "glycogenolysis": { + "label": "Glicogenolisi", + "description": "Il fegato libera glucosio dalle scorte di glicogeno. L'insulina cala." + }, + "lipolysis": { + "label": "Lipolisi", + "description": "Le scorte di grasso iniziano a essere mobilitate per produrre energia." + }, + "ketogenesis": { + "label": "Chetogenesi", + "description": "Il fegato produce corpi chetonici. Le sensazioni mentali si fanno più nitide." + }, + "autophagy": { + "label": "Autofagia", + "description": "Le cellule riciclano componenti danneggiati. Pulizia interna profonda." + } + }, + "startedAt": "Iniziato", + "target": "Obiettivo", + "protocolGuideTitle": "Guida ai protocolli", + "protocolHints": { + "FOURTEEN_TEN": { + "title": "Inizio dolce", + "description": "Adatto in fase di mantenimento o per chi inizia. 10 ore di alimentazione." + }, + "SIXTEEN_EIGHT": { + "title": "Lo standard", + "description": "Il protocollo più diffuso. Equilibrio tra sostenibilità e benefici metabolici." + }, + "EIGHTEEN_SIX": { + "title": "Più aggressivo", + "description": "Inizia a sollecitare la chetogenesi. Per chi è già adattato." + }, + "TWENTY_FOUR": { + "title": "Avanzato", + "description": "Solo 4 ore di alimentazione. Da praticare con regolarità ma non quotidianamente." + } + }, + "emptyHistory": "Nessuna sessione registrata. Avvia il primo digiuno per iniziare a costruire la serie.", + "statCompleted": "Sessioni concluse", + "statTotal": "Ore totali", + "statSessions": "Sessioni totali", + "status": { + "IN_PROGRESS": "In corso", + "COMPLETED": "Completata", + "ABORTED": "Annullata" + } + }, "Tracking": { "title": "Pesata e misure", "subtitle": "Pochi numeri, una volta a settimana. La tendenza emerge dalla regolarità — non dalla pesata di oggi.", diff --git a/apps/web/src/app/[locale]/fasting/actions.ts b/apps/web/src/app/[locale]/fasting/actions.ts new file mode 100644 index 0000000..1e42e6d --- /dev/null +++ b/apps/web/src/app/[locale]/fasting/actions.ts @@ -0,0 +1,83 @@ +'use server'; + +import { + fastEventStartSchema, + type FastEventStartInput, + type PROTOCOL_DEFAULT_MINUTES, +} 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 FastEventRow { + id: string; + protocol: keyof typeof PROTOCOL_DEFAULT_MINUTES; + status: 'IN_PROGRESS' | 'COMPLETED' | 'ABORTED'; + startedAt: string; // ISO + endedAt: string | null; + targetDuration: number; // minutes + symptoms: unknown | null; + notes: string | null; +} + +function cookieHeader(): string { + return headers().get('cookie') ?? ''; +} + +export async function fetchFastEvents(): Promise { + const res = await fetch(`${API_URL}/me/fast-events`, { + headers: { cookie: cookieHeader() }, + cache: 'no-store', + }); + if (!res.ok) return []; + const data = (await res.json()) as { events: FastEventRow[] }; + return data.events; +} + +export type StartResult = { ok: true; id: string } | { ok: false; error: string }; + +export async function startFast(input: FastEventStartInput): Promise { + const parsed = fastEventStartSchema.safeParse(input); + if (!parsed.success) { + return { ok: false, error: parsed.error.issues[0]?.message ?? 'invalid_input' }; + } + const res = await fetch(`${API_URL}/me/fast-events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', cookie: cookieHeader() }, + body: JSON.stringify({ + ...parsed.data, + startedAt: parsed.data.startedAt + ? parsed.data.startedAt.toISOString() + : new Date().toISOString(), + }), + 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}` }; + } + const data = (await res.json()) as { event: { id: string } }; + revalidatePath('/fasting'); + return { ok: true, id: data.event.id }; +} + +export type EndResult = { ok: true } | { ok: false; error: string }; + +export async function endFast(id: string, abort: boolean = false): Promise { + const res = await fetch(`${API_URL}/me/fast-events/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', cookie: cookieHeader() }, + body: JSON.stringify({ + endedAt: new Date().toISOString(), + status: abort ? 'ABORTED' : 'COMPLETED', + }), + 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 }; +} diff --git a/apps/web/src/app/[locale]/fasting/fast-history.tsx b/apps/web/src/app/[locale]/fasting/fast-history.tsx new file mode 100644 index 0000000..128508a --- /dev/null +++ b/apps/web/src/app/[locale]/fasting/fast-history.tsx @@ -0,0 +1,98 @@ +import { useTranslations } from 'next-intl'; + +import type { FastEventRow } from './actions'; + +const PROTOCOL_LABEL: Record = { + FOURTEEN_TEN: '14:10', + SIXTEEN_EIGHT: '16:8', + EIGHTEEN_SIX: '18:6', + TWENTY_FOUR: '20:4', + ESE_24: 'ESE 24h', + FIVE_TWO: '5:2', +}; + +const DATE_FMT = new Intl.DateTimeFormat('it-IT', { + weekday: 'short', + day: 'numeric', + month: 'short', +}); + +function pad(n: number): string { + return String(n).padStart(2, '0'); +} + +function durationLabel(startIso: string, endIso: string | null): string { + if (!endIso) return '—'; + const ms = new Date(endIso).getTime() - new Date(startIso).getTime(); + const h = Math.floor(ms / 3_600_000); + const m = Math.floor((ms % 3_600_000) / 60_000); + return `${h}h ${pad(m)}m`; +} + +function formatDate(iso: string): string { + return DATE_FMT.format(new Date(iso)); +} + +export function FastHistory({ events }: { events: FastEventRow[] }) { + const t = useTranslations('Fasting'); + + if (events.length === 0) { + return ( +

+ {t('emptyHistory')} +

+ ); + } + + 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; + + return ( +
+
+ + + +
+
    + {events.slice(0, 12).map((e) => ( +
  • + + {PROTOCOL_LABEL[e.protocol]} + +
    +

    + {formatDate(e.startedAt)} +

    +

    + {t(`status.${e.status}`)} +

    +
    +

    + {durationLabel(e.startedAt, e.endedAt)} +

    +
  • + ))} +
+
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

+ {value} +

+
+ ); +} diff --git a/apps/web/src/app/[locale]/fasting/fast-timer.tsx b/apps/web/src/app/[locale]/fasting/fast-timer.tsx new file mode 100644 index 0000000..cb8ba7f --- /dev/null +++ b/apps/web/src/app/[locale]/fasting/fast-timer.tsx @@ -0,0 +1,233 @@ +'use client'; + +import { FASTING_PROTOCOLS, PROTOCOL_DEFAULT_MINUTES } from '@ketopath/shared'; +import { useTranslations } from 'next-intl'; +import { useEffect, useState, useTransition } from 'react'; + +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +import { endFast, startFast, type FastEventRow } from './actions'; + +type Protocol = (typeof FASTING_PROTOCOLS)[number]; + +const PROTOCOL_LABEL: Record = { + FOURTEEN_TEN: '14:10', + SIXTEEN_EIGHT: '16:8', + EIGHTEEN_SIX: '18:6', + TWENTY_FOUR: '20:4', + ESE_24: 'ESE 24h', + FIVE_TWO: '5:2', +}; + +// PRD §5.3 — fasi metaboliche del digiuno (ore di digiuno trascorse). +function metabolicPhaseKey(hoursElapsed: number): string { + if (hoursElapsed < 4) return 'postprandial'; + if (hoursElapsed < 12) return 'glycogenolysis'; + if (hoursElapsed < 18) return 'lipolysis'; + if (hoursElapsed < 24) return 'ketogenesis'; + return 'autophagy'; +} + +function pad(n: number): string { + return String(n).padStart(2, '0'); +} + +function formatElapsed(seconds: number): string { + const sec = Math.max(0, Math.floor(seconds)); + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + const s = sec % 60; + return `${pad(h)}:${pad(m)}:${pad(s)}`; +} + +export function FastTimer({ active }: { active: FastEventRow | null }) { + if (active) { + return ; + } + return ; +} + +function ActiveTimer({ event }: { event: FastEventRow }) { + const t = useTranslations('Fasting'); + const startMs = new Date(event.startedAt).getTime(); + const targetMs = event.targetDuration * 60 * 1000; + const [now, setNow] = useState(() => Date.now()); + const [pending, startTransition] = useTransition(); + + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, []); + + const elapsedMs = Math.max(0, now - startMs); + const remainingMs = Math.max(0, targetMs - elapsedMs); + const reached = elapsedMs >= targetMs; + const progress = Math.min(1, elapsedMs / targetMs); + const hoursElapsed = elapsedMs / 1000 / 3600; + const phase = metabolicPhaseKey(hoursElapsed); + + function complete() { + startTransition(async () => { + await endFast(event.id, false); + }); + } + function abort() { + if (typeof window !== 'undefined' && !window.confirm(t('confirmAbort'))) return; + startTransition(async () => { + await endFast(event.id, true); + }); + } + + return ( +
+
+

+ {t('activeBadge')} · {PROTOCOL_LABEL[event.protocol]} +

+

+ {formatElapsed(elapsedMs / 1000)} +

+

+ {reached ? t('reached') : t('remaining', { time: formatElapsed(remainingMs / 1000) })} +

+ +
+ + +
+
+ +
+ ); +} + +function ProtocolSelector() { + const t = useTranslations('Fasting'); + const [protocol, setProtocol] = useState('SIXTEEN_EIGHT'); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + function start() { + setError(null); + startTransition(async () => { + const result = await startFast({ protocol }); + if (!result.ok) setError(result.error); + }); + } + + return ( +
+
+

{t('inactiveBadge')}

+

+ {t('startCallToAction')} +

+
+

{t('chooseProtocol')}

+ + {error ? ( +

+ {error} +

+ ) : null} +
+ +
+
+
+ +
+ ); +} + +function ProgressBar({ progress, reached }: { progress: number; reached: boolean }) { + return ( +
+
+
+ ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +const HOUR_FMT = new Intl.DateTimeFormat('it-IT', { + weekday: 'short', + hour: '2-digit', + minute: '2-digit', +}); + +function formatStart(iso: string): string { + return HOUR_FMT.format(new Date(iso)); +} diff --git a/apps/web/src/app/[locale]/fasting/page.tsx b/apps/web/src/app/[locale]/fasting/page.tsx new file mode 100644 index 0000000..74ff822 --- /dev/null +++ b/apps/web/src/app/[locale]/fasting/page.tsx @@ -0,0 +1,64 @@ +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 { fetchFastEvents } from './actions'; +import { FastHistory } from './fast-history'; +import { FastTimer } from './fast-timer'; + +export default async function FastingPage({ 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 }, + }); + if (!user?.disclaimerAcceptedAt) redirect('/welcome'); + + const events = await fetchFastEvents(); + const active = events.find((e) => e.status === 'IN_PROGRESS') ?? null; + const history = events.filter((e) => e.id !== active?.id); + + return ; +} + +function FastingPageContent({ + active, + history, +}: { + active: Awaited>[number] | null; + history: Awaited>; +}) { + const t = useTranslations('Fasting'); + + return ( +
+ +
+

Capitolo VII

+

+ {t('title')} + . +

+

+ {t('subtitle')} +

+ +
+ + + +
+

{t('historyTitle')}

+ +
+
+
+ ); +} diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx index 39aa6e4..de2ae57 100644 --- a/apps/web/src/app/[locale]/page.tsx +++ b/apps/web/src/app/[locale]/page.tsx @@ -104,6 +104,7 @@ function SignedInBlock({
    +