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

+136 -4
View File
@@ -17,25 +17,41 @@ import type { ScheduledTask } from 'node-cron';
import { pushIsEnabled, sendToDevice } from './sender.js';
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(
prisma: ExtendedPrismaClient,
log: FastifyBaseLogger,
): ScheduledTask | null {
): SchedulerHandles | null {
if (!pushIsEnabled()) {
log.info('[notifications] VAPID keys missing — scheduler disabled');
return null;
}
// eslint-disable-next-line import/no-named-as-default-member
const task = cron.schedule(
const weekly = cron.schedule(
WEEKLY_CRON,
() => {
void runWeeklyWeighInJob(prisma, log);
},
{ timezone: 'Europe/Rome' },
);
log.info('[notifications] weekly weigh-in cron scheduled (Mon 09:00 Europe/Rome)');
return task;
// eslint-disable-next-line import/no-named-as-default-member
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(
@@ -87,3 +103,119 @@ function readSettings(value: unknown): NotificationSettings {
const parsed = notificationSettingsSchema.safeParse(value);
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 } };
});
// 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,
};
});
};