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:
1 parent
c37dc5d717
commit
ea88ed8775
16 files changed
+810
-54
No files matched your search
@@ -17,25 +17,41 @@ import type { ScheduledTask } from 'node-cron';
|
|||||||
import { pushIsEnabled, sendToDevice } from './sender.js';
|
import { pushIsEnabled, sendToDevice } from './sender.js';
|
||||||
|
|
||||||
const WEEKLY_CRON = '0 9 * * 1'; // lunedì alle 09:00
|
const WEEKLY_CRON = '0 9 * * 1'; // lunedì alle 09:00
|
||||||
|
const FASTING_CRON = '*/5 * * * *'; // ogni 5 minuti
|
||||||
|
|
||||||
|
export interface SchedulerHandles {
|
||||||
|
weekly: ScheduledTask;
|
||||||
|
fasting: ScheduledTask;
|
||||||
|
}
|
||||||
|
|
||||||
export function startNotificationScheduler(
|
export function startNotificationScheduler(
|
||||||
prisma: ExtendedPrismaClient,
|
prisma: ExtendedPrismaClient,
|
||||||
log: FastifyBaseLogger,
|
log: FastifyBaseLogger,
|
||||||
): ScheduledTask | null {
|
): SchedulerHandles | null {
|
||||||
if (!pushIsEnabled()) {
|
if (!pushIsEnabled()) {
|
||||||
log.info('[notifications] VAPID keys missing — scheduler disabled');
|
log.info('[notifications] VAPID keys missing — scheduler disabled');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line import/no-named-as-default-member
|
// eslint-disable-next-line import/no-named-as-default-member
|
||||||
const task = cron.schedule(
|
const weekly = cron.schedule(
|
||||||
WEEKLY_CRON,
|
WEEKLY_CRON,
|
||||||
() => {
|
() => {
|
||||||
void runWeeklyWeighInJob(prisma, log);
|
void runWeeklyWeighInJob(prisma, log);
|
||||||
},
|
},
|
||||||
{ timezone: 'Europe/Rome' },
|
{ timezone: 'Europe/Rome' },
|
||||||
);
|
);
|
||||||
log.info('[notifications] weekly weigh-in cron scheduled (Mon 09:00 Europe/Rome)');
|
// eslint-disable-next-line import/no-named-as-default-member
|
||||||
return task;
|
const fasting = cron.schedule(
|
||||||
|
FASTING_CRON,
|
||||||
|
() => {
|
||||||
|
void runFastingMilestonesJob(prisma, log);
|
||||||
|
},
|
||||||
|
{ timezone: 'Europe/Rome' },
|
||||||
|
);
|
||||||
|
log.info(
|
||||||
|
'[notifications] cron scheduled — weekly weigh-in (Mon 09:00) + fasting milestones (every 5 min)',
|
||||||
|
);
|
||||||
|
return { weekly, fasting };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runWeeklyWeighInJob(
|
export async function runWeeklyWeighInJob(
|
||||||
@@ -87,3 +103,119 @@ function readSettings(value: unknown): NotificationSettings {
|
|||||||
const parsed = notificationSettingsSchema.safeParse(value);
|
const parsed = notificationSettingsSchema.safeParse(value);
|
||||||
return parsed.success ? parsed.data : DEFAULT_NOTIFICATION_SETTINGS;
|
return parsed.success ? parsed.data : DEFAULT_NOTIFICATION_SETTINGS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PRD §5.3 — milestone per le sessioni di digiuno IN_PROGRESS.
|
||||||
|
// Pattern: ogni 5 minuti scansiona le sessioni attive e invia push per le
|
||||||
|
// milestone "scadute" non ancora notificate. `notifiedMilestones` su FastEvent
|
||||||
|
// previene duplicati. Skipping se utente in pausa (fastingPausedUntil futuro).
|
||||||
|
type Milestone =
|
||||||
|
| 'hydration_2h'
|
||||||
|
| 'hydration_4h'
|
||||||
|
| 'hydration_6h'
|
||||||
|
| 'electrolyte_18h'
|
||||||
|
| 'ending_soon';
|
||||||
|
|
||||||
|
interface MilestoneSpec {
|
||||||
|
key: Milestone;
|
||||||
|
/** Vero se è il momento di notificarla data l'ora di start + target. */
|
||||||
|
isDue: (elapsedMin: number, targetMin: number) => boolean;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MILESTONES: MilestoneSpec[] = [
|
||||||
|
{
|
||||||
|
key: 'hydration_2h',
|
||||||
|
isDue: (m) => m >= 120,
|
||||||
|
title: 'Idratazione',
|
||||||
|
body: "Bevi un bicchiere d'acqua o tè non zuccherato.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'hydration_4h',
|
||||||
|
isDue: (m) => m >= 240,
|
||||||
|
title: 'Idratazione',
|
||||||
|
body: "Mantieni il ritmo: un altro bicchiere d'acqua aiuta.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'hydration_6h',
|
||||||
|
isDue: (m) => m >= 360,
|
||||||
|
title: 'Idratazione',
|
||||||
|
body: 'Hai superato le 6 ore: bevi e prosegui.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'electrolyte_18h',
|
||||||
|
isDue: (m) => m >= 1080,
|
||||||
|
title: 'Elettroliti',
|
||||||
|
body: 'Sopra le 18 ore: un pizzico di sale o un brodo aiutano a evitare il calo.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ending_soon',
|
||||||
|
isDue: (m, target) => target - m <= 30 && target - m >= 0,
|
||||||
|
title: 'Manca poco',
|
||||||
|
body: 'Tra 30 minuti puoi rompere il digiuno. Pianifica il primo pasto.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function runFastingMilestonesJob(
|
||||||
|
prisma: ExtendedPrismaClient,
|
||||||
|
log: FastifyBaseLogger,
|
||||||
|
): Promise<{ checked: number; sent: number; expired: number }> {
|
||||||
|
const now = Date.now();
|
||||||
|
const active = await prisma.fastEvent.findMany({
|
||||||
|
where: { status: 'IN_PROGRESS' },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
fastingPausedUntil: true,
|
||||||
|
deviceTokens: true,
|
||||||
|
preferences: { select: { notificationSettings: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let sent = 0;
|
||||||
|
let expired = 0;
|
||||||
|
for (const fast of active) {
|
||||||
|
const settings = readSettings(fast.user.preferences?.notificationSettings ?? null);
|
||||||
|
if (!settings.fastingMilestones) continue;
|
||||||
|
if (fast.user.fastingPausedUntil && fast.user.fastingPausedUntil.getTime() > now) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const elapsedMin = (now - fast.startedAt.getTime()) / 60_000;
|
||||||
|
const already = new Set(fast.notifiedMilestones);
|
||||||
|
const toSend: MilestoneSpec[] = MILESTONES.filter(
|
||||||
|
(m) => !already.has(m.key) && m.isDue(elapsedMin, fast.targetDuration),
|
||||||
|
);
|
||||||
|
if (toSend.length === 0) continue;
|
||||||
|
if (fast.user.deviceTokens.length === 0) continue;
|
||||||
|
|
||||||
|
for (const milestone of toSend) {
|
||||||
|
for (const token of fast.user.deviceTokens) {
|
||||||
|
const r = await sendToDevice(token, {
|
||||||
|
title: milestone.title,
|
||||||
|
body: milestone.body,
|
||||||
|
url: '/fasting',
|
||||||
|
});
|
||||||
|
if (r.ok) sent++;
|
||||||
|
if (r.expired) {
|
||||||
|
expired++;
|
||||||
|
await prisma.deviceToken.delete({ where: { id: token.id } }).catch(() => {
|
||||||
|
/* ignora race */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await prisma.fastEvent.update({
|
||||||
|
where: { id: fast.id },
|
||||||
|
data: {
|
||||||
|
notifiedMilestones: { push: toSend.map((m) => m.key) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sent > 0 || expired > 0) {
|
||||||
|
log.info({ checked: active.length, sent, expired }, '[notifications] fasting milestones job');
|
||||||
|
}
|
||||||
|
return { checked: active.length, sent, expired };
|
||||||
|
}
|
||||||
@@ -73,4 +73,89 @@ export const fastRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
});
|
});
|
||||||
return { event: { id: event.id, status: event.status } };
|
return { event: { id: event.id, status: event.status } };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PRD §5.3 — modalità "giorno libero". Mette in pausa i reminder fino a
|
||||||
|
// `until` (default: domani alle 06:00 nel fuso del server). Idempotente:
|
||||||
|
// body vuoto = pausa fino a domattina; { until: null } = riprende subito.
|
||||||
|
fastify.post('/me/fasting/pause', { preHandler: requireAuth() }, async (request, reply) => {
|
||||||
|
const userId = request.user!.id;
|
||||||
|
const body = (request.body ?? {}) as { until?: string | null };
|
||||||
|
let until: Date | null = null;
|
||||||
|
if (body.until === null) {
|
||||||
|
until = null;
|
||||||
|
} else if (typeof body.until === 'string') {
|
||||||
|
const d = new Date(body.until);
|
||||||
|
if (Number.isNaN(d.getTime())) {
|
||||||
|
return reply.code(400).send({ error: 'invalid_until' });
|
||||||
|
}
|
||||||
|
until = d;
|
||||||
|
} else {
|
||||||
|
// default: domani alle 06:00 locali (server)
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
tomorrow.setHours(6, 0, 0, 0);
|
||||||
|
until = tomorrow;
|
||||||
|
}
|
||||||
|
const user = await fastify.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { fastingPausedUntil: until },
|
||||||
|
select: { fastingPausedUntil: true },
|
||||||
|
});
|
||||||
|
return { fastingPausedUntil: user.fastingPausedUntil?.toISOString() ?? null };
|
||||||
|
});
|
||||||
|
|
||||||
|
// PRD §5.3 — pasto di rottura intelligente. Per digiuni > 24h propone 3
|
||||||
|
// ricette facili da digerire: prep <= 20 min, kcal <= 350, nessun ingrediente
|
||||||
|
// a forte impatto digestivo (legumi, pesce affumicato, fritture). Heuristica
|
||||||
|
// semplice: prepMinutes + kcal + categoria SPUNTINO/COLAZIONE.
|
||||||
|
fastify.get(
|
||||||
|
'/me/fast-events/:id/break-fast-suggestions',
|
||||||
|
{ preHandler: requireAuth() },
|
||||||
|
async (request, reply) => {
|
||||||
|
const id = (request.params as { id: string }).id;
|
||||||
|
const userId = request.user!.id;
|
||||||
|
const fast = await fastify.prisma.fastEvent.findFirst({
|
||||||
|
where: { id, userId },
|
||||||
|
select: { startedAt: true, endedAt: true, targetDuration: true, status: true },
|
||||||
|
});
|
||||||
|
if (!fast) return reply.code(404).send({ error: 'fast_event_not_found' });
|
||||||
|
// Disponibile solo per digiuni effettivamente >= 24h
|
||||||
|
const end = fast.endedAt ?? new Date();
|
||||||
|
const hours = (end.getTime() - fast.startedAt.getTime()) / 3_600_000;
|
||||||
|
if (hours < 24) {
|
||||||
|
return reply.code(409).send({ error: 'duration_too_short', hours });
|
||||||
|
}
|
||||||
|
const recipes = await fastify.prisma.recipe.findMany({
|
||||||
|
where: {
|
||||||
|
prepMinutes: { lte: 20 },
|
||||||
|
kcal: { lte: 350 },
|
||||||
|
category: { in: ['COLAZIONE', 'SPUNTINO'] },
|
||||||
|
},
|
||||||
|
orderBy: [{ kcal: 'asc' }, { prepMinutes: 'asc' }],
|
||||||
|
take: 3,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
kcal: true,
|
||||||
|
proteinG: true,
|
||||||
|
prepMinutes: true,
|
||||||
|
description: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { suggestions: recipes };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.get('/me/fasting/pause', { preHandler: requireAuth() }, async (request) => {
|
||||||
|
const user = await fastify.prisma.user.findUnique({
|
||||||
|
where: { id: request.user!.id },
|
||||||
|
select: { fastingPausedUntil: true },
|
||||||
|
});
|
||||||
|
const until = user?.fastingPausedUntil ?? null;
|
||||||
|
const active = until != null && until.getTime() > Date.now();
|
||||||
|
return {
|
||||||
|
fastingPausedUntil: until?.toISOString() ?? null,
|
||||||
|
paused: active,
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
@@ -9,9 +9,10 @@ import { startNotificationScheduler } from './modules/notifications/scheduler.js
|
|||||||
async function start(): Promise<void> {
|
async function start(): Promise<void> {
|
||||||
const app = await buildApp();
|
const app = await buildApp();
|
||||||
|
|
||||||
const cronTask = startNotificationScheduler(app.prisma, app.log);
|
const cronHandles = startNotificationScheduler(app.prisma, app.log);
|
||||||
app.addHook('onClose', async () => {
|
app.addHook('onClose', async () => {
|
||||||
cronTask?.stop();
|
cronHandles?.weekly.stop();
|
||||||
|
cronHandles?.fasting.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -136,6 +136,30 @@
|
|||||||
"statCompleted": "Sessioni concluse",
|
"statCompleted": "Sessioni concluse",
|
||||||
"statTotal": "Ore totali",
|
"statTotal": "Ore totali",
|
||||||
"statSessions": "Sessioni 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": {
|
"status": {
|
||||||
"IN_PROGRESS": "In corso",
|
"IN_PROGRESS": "In corso",
|
||||||
"COMPLETED": "Completata",
|
"COMPLETED": "Completata",
|
||||||
|
|||||||
@@ -25,6 +25,56 @@ function cookieHeader(): string {
|
|||||||
return headers().get('cookie') ?? '';
|
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[]> {
|
export async function fetchFastEvents(): Promise<FastEventRow[]> {
|
||||||
const res = await fetch(`${API_URL}/me/fast-events`, {
|
const res = await fetch(`${API_URL}/me/fast-events`, {
|
||||||
headers: { cookie: cookieHeader() },
|
headers: { cookie: cookieHeader() },
|
||||||
@@ -62,6 +112,32 @@ export async function startFast(input: FastEventStartInput): Promise<StartResult
|
|||||||
return { ok: true, id: data.event.id };
|
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 type EndResult = { ok: true } | { ok: false; error: string };
|
||||||
|
|
||||||
export async function endFast(id: string, abort: boolean = false): Promise<EndResult> {
|
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 { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
import type { FastEventRow } from './actions';
|
import type { FastEventRow } from './actions';
|
||||||
|
import { BreakFastSuggestionsBlock } from './break-fast-suggestions';
|
||||||
|
|
||||||
const PROTOCOL_LABEL: Record<FastEventRow['protocol'], string> = {
|
const PROTOCOL_LABEL: Record<FastEventRow['protocol'], string> = {
|
||||||
FOURTEEN_TEN: '14:10',
|
FOURTEEN_TEN: '14:10',
|
||||||
@@ -44,43 +46,54 @@ export function FastHistory({ events }: { events: FastEventRow[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const completed = events.filter((e) => e.status === 'COMPLETED');
|
const stats = computeFastingStats(events);
|
||||||
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="border-ink/10 grid grid-cols-2 gap-6 border-y py-5 sm:grid-cols-3">
|
<div className="border-ink/10 grid grid-cols-2 gap-6 border-y py-5 sm:grid-cols-5">
|
||||||
<Stat label={t('statCompleted')} value={String(completed.length)} />
|
<Stat label={t('statWeekly')} value={`${stats.weeklyHours.toFixed(1)} h`} />
|
||||||
<Stat label={t('statTotal')} value={`${totalHours.toFixed(1)} h`} />
|
<Stat label={t('statMonthly')} value={`${stats.monthlyHours.toFixed(1)} h`} />
|
||||||
<Stat label={t('statSessions')} value={String(events.length)} />
|
<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>
|
</div>
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{events.slice(0, 12).map((e) => (
|
{events.slice(0, 12).map((e) => {
|
||||||
<li
|
const hours = e.endedAt
|
||||||
key={e.id}
|
? (new Date(e.endedAt).getTime() - new Date(e.startedAt).getTime()) / 3_600_000
|
||||||
className="border-ink/10 grid grid-cols-[auto_1fr_auto] items-baseline gap-4 border-b pb-3 last:border-b-0"
|
: 0;
|
||||||
>
|
const isLongFast = e.status === 'COMPLETED' && hours >= 24;
|
||||||
<span className="font-display text-pomodoro text-base italic tabular-nums">
|
return (
|
||||||
{PROTOCOL_LABEL[e.protocol]}
|
<li key={e.id} className="border-ink/10 space-y-3 border-b pb-3 last:border-b-0">
|
||||||
</span>
|
<div className="grid grid-cols-[auto_1fr_auto] items-baseline gap-4">
|
||||||
<div>
|
<span className="font-display text-pomodoro text-base italic tabular-nums">
|
||||||
<p className="font-display text-ink text-base leading-tight">
|
{PROTOCOL_LABEL[e.protocol]}
|
||||||
{formatDate(e.startedAt)}
|
</span>
|
||||||
</p>
|
<div>
|
||||||
<p className="text-ink-dim mt-0.5 font-mono text-[10px] uppercase tracking-widest">
|
<p className="font-display text-ink text-base leading-tight">
|
||||||
{t(`status.${e.status}`)}
|
{formatDate(e.startedAt)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<p className="text-ink-dim mt-0.5 font-mono text-[10px] uppercase tracking-widest">
|
||||||
<p className="text-ink font-mono text-sm tabular-nums">
|
{t(`status.${e.status}`)}
|
||||||
{durationLabel(e.startedAt, e.endedAt)}
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</li>
|
<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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} 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];
|
type Protocol = (typeof FASTING_PROTOCOLS)[number];
|
||||||
|
|
||||||
@@ -107,18 +107,21 @@ function ActiveTimer({ event }: { event: FastEventRow }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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">
|
<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">
|
||||||
<p className="editorial-eyebrow">{t('phaseTitle')}</p>
|
<div>
|
||||||
<p className="font-display text-pomodoro mt-4 text-2xl font-medium leading-tight">
|
<p className="editorial-eyebrow">{t('phaseTitle')}</p>
|
||||||
{t(`phases.${phase}.label`)}
|
<p className="font-display text-pomodoro mt-4 text-2xl font-medium leading-tight">
|
||||||
</p>
|
{t(`phases.${phase}.label`)}
|
||||||
<p className="font-display text-ink-soft mt-3 text-base italic leading-snug">
|
</p>
|
||||||
{t(`phases.${phase}.description`)}
|
<p className="font-display text-ink-soft mt-3 text-base italic leading-snug">
|
||||||
</p>
|
{t(`phases.${phase}.description`)}
|
||||||
<dl className="mt-8 space-y-5 text-sm">
|
</p>
|
||||||
<Row label={t('startedAt')} value={formatStart(event.startedAt)} />
|
<dl className="mt-8 space-y-5 text-sm">
|
||||||
<Row label={t('target')} value={`${(event.targetDuration / 60).toFixed(1)} h`} />
|
<Row label={t('startedAt')} value={formatStart(event.startedAt)} />
|
||||||
</dl>
|
<Row label={t('target')} value={`${(event.targetDuration / 60).toFixed(1)} h`} />
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<SymptomsDiary event={event} />
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -231,3 +234,109 @@ const HOUR_FMT = new Intl.DateTimeFormat('it-IT', {
|
|||||||
function formatStart(iso: string): string {
|
function formatStart(iso: string): string {
|
||||||
return HOUR_FMT.format(new Date(iso));
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,9 +6,10 @@ import { setRequestLocale } from 'next-intl/server';
|
|||||||
import { Masthead } from '@/components/masthead';
|
import { Masthead } from '@/components/masthead';
|
||||||
import { getServerSession } from '@/lib/auth';
|
import { getServerSession } from '@/lib/auth';
|
||||||
|
|
||||||
import { fetchFastEvents } from './actions';
|
import { fetchFastEvents, fetchFastingPause, type FastingPauseStatus } from './actions';
|
||||||
import { FastHistory } from './fast-history';
|
import { FastHistory } from './fast-history';
|
||||||
import { FastTimer } from './fast-timer';
|
import { FastTimer } from './fast-timer';
|
||||||
|
import { FastingPauseToggle } from './fasting-pause-toggle';
|
||||||
|
|
||||||
export default async function FastingPage({ params: { locale } }: { params: { locale: string } }) {
|
export default async function FastingPage({ params: { locale } }: { params: { locale: string } }) {
|
||||||
setRequestLocale(locale);
|
setRequestLocale(locale);
|
||||||
@@ -21,19 +22,21 @@ export default async function FastingPage({ params: { locale } }: { params: { lo
|
|||||||
});
|
});
|
||||||
if (!user?.disclaimerAcceptedAt) redirect('/welcome');
|
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 active = events.find((e) => e.status === 'IN_PROGRESS') ?? null;
|
||||||
const history = events.filter((e) => e.id !== active?.id);
|
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({
|
function FastingPageContent({
|
||||||
active,
|
active,
|
||||||
history,
|
history,
|
||||||
|
pauseStatus,
|
||||||
}: {
|
}: {
|
||||||
active: Awaited<ReturnType<typeof fetchFastEvents>>[number] | null;
|
active: Awaited<ReturnType<typeof fetchFastEvents>>[number] | null;
|
||||||
history: Awaited<ReturnType<typeof fetchFastEvents>>;
|
history: Awaited<ReturnType<typeof fetchFastEvents>>;
|
||||||
|
pauseStatus: FastingPauseStatus;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations('Fasting');
|
const t = useTranslations('Fasting');
|
||||||
|
|
||||||
@@ -52,6 +55,8 @@ function FastingPageContent({
|
|||||||
|
|
||||||
<div className="rule animate-rule-in my-10 [animation-delay:360ms]" />
|
<div className="rule animate-rule-in my-10 [animation-delay:360ms]" />
|
||||||
|
|
||||||
|
<FastingPauseToggle initial={pauseStatus} />
|
||||||
|
|
||||||
<FastTimer active={active} />
|
<FastTimer active={active} />
|
||||||
|
|
||||||
<div className="animate-fade-up mt-16 [animation-delay:540ms]">
|
<div className="animate-fade-up mt-16 [animation-delay:540ms]">
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "fasting_paused_until" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "fast_events" ADD COLUMN "notified_milestones" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||||
@@ -89,6 +89,9 @@ model User {
|
|||||||
// PRD §5.1 — momento in cui l'utente è entrato nella fase 2 (TRANSITION).
|
// PRD §5.1 — momento in cui l'utente è entrato nella fase 2 (TRANSITION).
|
||||||
// Serve a calcolare la "settimana di fase" per la reintroduzione progressiva.
|
// Serve a calcolare la "settimana di fase" per la reintroduzione progressiva.
|
||||||
phase2StartedAt DateTime? @map("phase2_started_at")
|
phase2StartedAt DateTime? @map("phase2_started_at")
|
||||||
|
// PRD §5.3 — modalità "giorno libero" del digiuno: niente reminder, niente
|
||||||
|
// start automatico finché il timestamp non è passato. NULL = nessuna pausa.
|
||||||
|
fastingPausedUntil DateTime? @map("fasting_paused_until")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
@@ -263,6 +266,9 @@ model FastEvent {
|
|||||||
symptoms String?
|
symptoms String?
|
||||||
/// @encrypted
|
/// @encrypted
|
||||||
notes String?
|
notes String?
|
||||||
|
// PRD §5.3 — milestone già notificate per evitare duplicati. Array di chiavi
|
||||||
|
// tipo "hydration_2h", "hydration_4h", "electrolyte_18h", "ending_soon".
|
||||||
|
notifiedMilestones String[] @default([]) @map("notified_milestones")
|
||||||
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ export * from './planner/types.js';
|
|||||||
export * from './preferences/training.js';
|
export * from './preferences/training.js';
|
||||||
export * from './profile/diet-history.js';
|
export * from './profile/diet-history.js';
|
||||||
export * from './profile/schema.js';
|
export * from './profile/schema.js';
|
||||||
|
export * from './tracking/fasting-stats.js';
|
||||||
export * from './tracking/schema.js';
|
export * from './tracking/schema.js';
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { computeFastingStats, type FastingEventLike } from './fasting-stats.js';
|
||||||
|
|
||||||
|
const HOUR_MS = 3_600_000;
|
||||||
|
const DAY_MS = 24 * HOUR_MS;
|
||||||
|
|
||||||
|
function ev(
|
||||||
|
daysAgo: number,
|
||||||
|
hours: number,
|
||||||
|
status: FastingEventLike['status'] = 'COMPLETED',
|
||||||
|
): FastingEventLike {
|
||||||
|
const now = new Date('2026-04-29T12:00:00Z');
|
||||||
|
const start = new Date(now.getTime() - daysAgo * DAY_MS);
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
startedAt: start.toISOString(),
|
||||||
|
endedAt:
|
||||||
|
status === 'IN_PROGRESS' ? null : new Date(start.getTime() + hours * HOUR_MS).toISOString(),
|
||||||
|
targetDuration: hours * 60,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOW = new Date('2026-04-29T12:00:00Z');
|
||||||
|
|
||||||
|
describe('computeFastingStats', () => {
|
||||||
|
it('vuoto → tutti zeri', () => {
|
||||||
|
const s = computeFastingStats([], NOW);
|
||||||
|
expect(s).toEqual({
|
||||||
|
weeklyHours: 0,
|
||||||
|
monthlyHours: 0,
|
||||||
|
currentStreak: 0,
|
||||||
|
longestStreak: 0,
|
||||||
|
completionRate: 0,
|
||||||
|
totalSessions: 0,
|
||||||
|
completedSessions: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weeklyHours somma solo sessioni completate negli ultimi 7 giorni', () => {
|
||||||
|
const s = computeFastingStats([ev(0, 16), ev(2, 18), ev(10, 24)], NOW);
|
||||||
|
expect(s.weeklyHours).toBeCloseTo(34, 5);
|
||||||
|
expect(s.monthlyHours).toBeCloseTo(58, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('currentStreak: 3 giorni consecutivi con sessione completed', () => {
|
||||||
|
const s = computeFastingStats([ev(0, 16), ev(1, 16), ev(2, 16), ev(4, 16)], NOW);
|
||||||
|
expect(s.currentStreak).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('currentStreak salta se manca un giorno', () => {
|
||||||
|
const s = computeFastingStats([ev(0, 16), ev(2, 16)], NOW);
|
||||||
|
expect(s.currentStreak).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('completionRate ignora sessioni in corso', () => {
|
||||||
|
const s = computeFastingStats(
|
||||||
|
[ev(0, 16, 'COMPLETED'), ev(1, 8, 'ABORTED'), ev(2, 0, 'IN_PROGRESS')],
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect(s.completionRate).toBeCloseTo(0.5, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('longestStreak storico', () => {
|
||||||
|
const events = [ev(0, 16), ev(1, 16), ev(2, 16), ev(3, 16), ev(10, 16), ev(11, 16)];
|
||||||
|
const s = computeFastingStats(events, NOW);
|
||||||
|
expect(s.longestStreak).toBeGreaterThanOrEqual(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// PRD §5.3 — Statistiche del digiuno: streak, ore totali, completion rate.
|
||||||
|
//
|
||||||
|
// Funzione pura, riutilizzabile da UI e API. Lavora su array di eventi già
|
||||||
|
// caricati dal DB.
|
||||||
|
|
||||||
|
export interface FastingEventLike {
|
||||||
|
status: 'IN_PROGRESS' | 'COMPLETED' | 'ABORTED';
|
||||||
|
startedAt: Date | string;
|
||||||
|
endedAt: Date | string | null;
|
||||||
|
targetDuration: number; // minuti
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FastingStats {
|
||||||
|
weeklyHours: number; // ore di digiuno completato negli ultimi 7 giorni
|
||||||
|
monthlyHours: number; // ultimi 30 giorni
|
||||||
|
currentStreak: number; // giorni consecutivi con almeno una sessione COMPLETED
|
||||||
|
longestStreak: number; // record di sempre
|
||||||
|
completionRate: number; // [0, 1] su sessioni terminate (COMPLETED+ABORTED)
|
||||||
|
totalSessions: number;
|
||||||
|
completedSessions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function toDate(d: Date | string): Date {
|
||||||
|
return typeof d === 'string' ? new Date(d) : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayKey(d: Date): string {
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationMs(e: FastingEventLike): number {
|
||||||
|
const start = toDate(e.startedAt).getTime();
|
||||||
|
const end = e.endedAt ? toDate(e.endedAt).getTime() : start;
|
||||||
|
return Math.max(0, end - start);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeFastingStats(
|
||||||
|
events: ReadonlyArray<FastingEventLike>,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): FastingStats {
|
||||||
|
const completed = events.filter((e) => e.status === 'COMPLETED');
|
||||||
|
const finished = events.filter((e) => e.status !== 'IN_PROGRESS');
|
||||||
|
|
||||||
|
const oneWeekAgo = now.getTime() - 7 * DAY_MS;
|
||||||
|
const oneMonthAgo = now.getTime() - 30 * DAY_MS;
|
||||||
|
const weeklyMs = completed
|
||||||
|
.filter((e) => toDate(e.startedAt).getTime() >= oneWeekAgo)
|
||||||
|
.reduce((sum, e) => sum + durationMs(e), 0);
|
||||||
|
const monthlyMs = completed
|
||||||
|
.filter((e) => toDate(e.startedAt).getTime() >= oneMonthAgo)
|
||||||
|
.reduce((sum, e) => sum + durationMs(e), 0);
|
||||||
|
|
||||||
|
// Streak: contiamo giorni consecutivi (anchored a oggi) in cui esiste
|
||||||
|
// almeno una sessione COMPLETED.
|
||||||
|
const completedDays = new Set(completed.map((e) => dayKey(toDate(e.startedAt))));
|
||||||
|
let currentStreak = 0;
|
||||||
|
for (let offset = 0; offset < 365; offset++) {
|
||||||
|
const candidate = new Date(now.getTime() - offset * DAY_MS);
|
||||||
|
if (completedDays.has(dayKey(candidate))) {
|
||||||
|
currentStreak++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Longest streak storico: scansiona i giorni completati ordinati e conta
|
||||||
|
// run consecutive.
|
||||||
|
const sortedDays = Array.from(completedDays).sort();
|
||||||
|
let longestStreak = 0;
|
||||||
|
let run = 0;
|
||||||
|
let prevTs: number | null = null;
|
||||||
|
for (const day of sortedDays) {
|
||||||
|
const ts = new Date(day + 'T00:00:00Z').getTime();
|
||||||
|
if (prevTs != null && ts - prevTs === DAY_MS) {
|
||||||
|
run++;
|
||||||
|
} else {
|
||||||
|
run = 1;
|
||||||
|
}
|
||||||
|
if (run > longestStreak) longestStreak = run;
|
||||||
|
prevTs = ts;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
weeklyHours: Number((weeklyMs / 3_600_000).toFixed(1)),
|
||||||
|
monthlyHours: Number((monthlyMs / 3_600_000).toFixed(1)),
|
||||||
|
currentStreak,
|
||||||
|
longestStreak,
|
||||||
|
completionRate: finished.length === 0 ? 0 : completed.length / finished.length,
|
||||||
|
totalSessions: events.length,
|
||||||
|
completedSessions: completed.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user