feat(web): fasting page — live timer, metabolic phase, history (PRD §5.3)
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
4e00c131b9
commit
b79340efb7
6 files changed
+551
No files matched your search
@@ -10,6 +10,7 @@
|
|||||||
"completeProfile": "Profilo & calcoli",
|
"completeProfile": "Profilo & calcoli",
|
||||||
"viewPlan": "Piano della settimana",
|
"viewPlan": "Piano della settimana",
|
||||||
"viewTracking": "Pesata & misure",
|
"viewTracking": "Pesata & misure",
|
||||||
|
"viewFasting": "Digiuno intermittente",
|
||||||
"lettura": "Continua la lettura",
|
"lettura": "Continua la lettura",
|
||||||
"inizia": "Inizia",
|
"inizia": "Inizia",
|
||||||
"greeting": "Buongiorno, <name>{name}</name>.",
|
"greeting": "Buongiorno, <name>{name}</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."
|
"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": {
|
"Tracking": {
|
||||||
"title": "Pesata e misure",
|
"title": "Pesata e misure",
|
||||||
"subtitle": "Pochi numeri, una volta a settimana. La tendenza emerge dalla regolarità — non dalla pesata di oggi.",
|
"subtitle": "Pochi numeri, una volta a settimana. La tendenza emerge dalla regolarità — non dalla pesata di oggi.",
|
||||||
|
|||||||
@@ -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<FastEventRow[]> {
|
||||||
|
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<StartResult> {
|
||||||
|
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<EndResult> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
import type { FastEventRow } from './actions';
|
||||||
|
|
||||||
|
const PROTOCOL_LABEL: Record<FastEventRow['protocol'], string> = {
|
||||||
|
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 (
|
||||||
|
<p className="font-display text-ink-dim max-w-md text-base italic leading-relaxed">
|
||||||
|
{t('emptyHistory')}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="editorial-eyebrow">{label}</p>
|
||||||
|
<p className="text-ink mt-2 font-mono text-2xl font-medium tabular-nums leading-none tracking-tight">
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Protocol, string> = {
|
||||||
|
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 <ActiveTimer event={active} />;
|
||||||
|
}
|
||||||
|
return <ProtocolSelector />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<number>(() => 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 (
|
||||||
|
<section className="grid gap-12 md:grid-cols-12">
|
||||||
|
<div className="animate-fade-up [animation-delay:300ms] md:col-span-7">
|
||||||
|
<p className="editorial-eyebrow">
|
||||||
|
{t('activeBadge')} · {PROTOCOL_LABEL[event.protocol]}
|
||||||
|
</p>
|
||||||
|
<p className="text-ink mt-6 font-mono text-[clamp(3rem,9vw,6.25rem)] font-medium tabular-nums leading-[0.9] tracking-tight">
|
||||||
|
{formatElapsed(elapsedMs / 1000)}
|
||||||
|
</p>
|
||||||
|
<p className="font-display text-ink-soft mt-3 text-xl italic leading-snug">
|
||||||
|
{reached ? t('reached') : t('remaining', { time: formatElapsed(remainingMs / 1000) })}
|
||||||
|
</p>
|
||||||
|
<ProgressBar progress={progress} reached={reached} />
|
||||||
|
<div className="mt-8 flex flex-wrap gap-3">
|
||||||
|
<Button type="button" onClick={complete} disabled={pending} size="lg">
|
||||||
|
{pending ? t('saving') : reached ? t('completeReached') : t('completeEarly')}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" onClick={abort} disabled={pending} size="lg">
|
||||||
|
{t('abort')}
|
||||||
|
</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>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProtocolSelector() {
|
||||||
|
const t = useTranslations('Fasting');
|
||||||
|
const [protocol, setProtocol] = useState<Protocol>('SIXTEEN_EIGHT');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await startFast({ protocol });
|
||||||
|
if (!result.ok) setError(result.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="grid gap-12 md:grid-cols-12">
|
||||||
|
<div className="animate-fade-up [animation-delay:300ms] md:col-span-7">
|
||||||
|
<p className="editorial-eyebrow">{t('inactiveBadge')}</p>
|
||||||
|
<p className="font-display text-ink mt-4 text-3xl italic leading-snug sm:text-4xl">
|
||||||
|
{t('startCallToAction')}
|
||||||
|
</p>
|
||||||
|
<div className="mt-10 max-w-sm space-y-3">
|
||||||
|
<p className="editorial-eyebrow">{t('chooseProtocol')}</p>
|
||||||
|
<Select value={protocol} onValueChange={(v) => setProtocol(v as Protocol)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FASTING_PROTOCOLS.map((p) => (
|
||||||
|
<SelectItem key={p} value={p}>
|
||||||
|
{PROTOCOL_LABEL[p]} · {(PROTOCOL_DEFAULT_MINUTES[p] / 60).toFixed(0)} h
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="font-display text-pomodoro text-base italic">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="pt-4">
|
||||||
|
<Button type="button" onClick={start} disabled={pending} size="lg" className="w-full">
|
||||||
|
{pending ? t('starting') : t('start')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</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('protocolGuideTitle')}</p>
|
||||||
|
<ol className="font-display text-ink mt-6 space-y-5 text-base leading-snug">
|
||||||
|
{(['FOURTEEN_TEN', 'SIXTEEN_EIGHT', 'EIGHTEEN_SIX', 'TWENTY_FOUR'] as const).map(
|
||||||
|
(p, i) => (
|
||||||
|
<li key={p} className="grid grid-cols-[2.5rem_1fr] gap-3">
|
||||||
|
<span className="text-ink-dim font-mono text-xs uppercase tracking-widest">
|
||||||
|
{['I', 'II', 'III', 'IV'][i]}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{PROTOCOL_LABEL[p]}{' '}
|
||||||
|
<span className="font-display text-ink-soft italic">
|
||||||
|
· {t(`protocolHints.${p}.title`)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-ink-soft mt-1 text-sm leading-snug">
|
||||||
|
{t(`protocolHints.${p}.description`)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</ol>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressBar({ progress, reached }: { progress: number; reached: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-rule mt-6 h-px w-full">
|
||||||
|
<div
|
||||||
|
className={`h-px origin-left transition-transform duration-700 ease-out ${
|
||||||
|
reached ? 'bg-pomodoro' : 'bg-ink'
|
||||||
|
}`}
|
||||||
|
style={{ transform: `scaleX(${progress})` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 items-baseline gap-3">
|
||||||
|
<dt className="editorial-eyebrow">{label}</dt>
|
||||||
|
<dd className="text-ink font-mono text-sm tabular-nums">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
@@ -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 <FastingPageContent active={active} history={history} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FastingPageContent({
|
||||||
|
active,
|
||||||
|
history,
|
||||||
|
}: {
|
||||||
|
active: Awaited<ReturnType<typeof fetchFastEvents>>[number] | null;
|
||||||
|
history: Awaited<ReturnType<typeof fetchFastEvents>>;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations('Fasting');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto min-h-screen max-w-5xl px-6 sm:px-10">
|
||||||
|
<Masthead issueLabel="N. 07 — Digiuno" />
|
||||||
|
<main className="py-12 sm:py-16">
|
||||||
|
<p className="editorial-eyebrow animate-fade-up">Capitolo VII</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]" />
|
||||||
|
|
||||||
|
<FastTimer active={active} />
|
||||||
|
|
||||||
|
<div className="animate-fade-up mt-16 [animation-delay:540ms]">
|
||||||
|
<p className="editorial-eyebrow mb-6">{t('historyTitle')}</p>
|
||||||
|
<FastHistory events={history} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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="/fasting" label={t('viewFasting')} eyebrow="Digiuno" />
|
||||||
<NavItem href="/tracking" label={t('viewTracking')} eyebrow="Tracking" />
|
<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>
|
||||||
|
|||||||
Reference in new issue
Block a user