From ea88ed8775b3c200b48b64fe59a327ed79c4e45f Mon Sep 17 00:00:00 2001 From: luciano Date: Wed, 29 Apr 2026 23:41:20 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=C2=A75.3=20completion=20=E2=80=94=20st?= =?UTF-8?q?ats,=20symptoms,=20milestones,=20pause,=20break-fast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/modules/notifications/scheduler.ts | 140 +++++++++++++++++- apps/api/src/modules/tracking/fast.routes.ts | 85 +++++++++++ apps/api/src/server.ts | 5 +- apps/web/messages/it.json | 24 +++ apps/web/src/app/[locale]/fasting/actions.ts | 76 ++++++++++ .../fasting/break-fast-suggestions.tsx | 64 ++++++++ .../src/app/[locale]/fasting/fast-history.tsx | 77 ++++++---- .../src/app/[locale]/fasting/fast-timer.tsx | 135 +++++++++++++++-- .../[locale]/fasting/fasting-pause-toggle.tsx | 73 +++++++++ apps/web/src/app/[locale]/fasting/page.tsx | 11 +- .../migration.sql | 2 + .../migration.sql | 2 + packages/db/prisma/schema.prisma | 6 + packages/shared/src/index.ts | 1 + .../shared/src/tracking/fasting-stats.test.ts | 69 +++++++++ packages/shared/src/tracking/fasting-stats.ts | 94 ++++++++++++ 16 files changed, 810 insertions(+), 54 deletions(-) create mode 100644 apps/web/src/app/[locale]/fasting/break-fast-suggestions.tsx create mode 100644 apps/web/src/app/[locale]/fasting/fasting-pause-toggle.tsx create mode 100644 packages/db/prisma/migrations/20260429213633_add_fasting_paused_until/migration.sql create mode 100644 packages/db/prisma/migrations/20260429213912_add_fast_event_milestones/migration.sql create mode 100644 packages/shared/src/tracking/fasting-stats.test.ts create mode 100644 packages/shared/src/tracking/fasting-stats.ts diff --git a/apps/api/src/modules/notifications/scheduler.ts b/apps/api/src/modules/notifications/scheduler.ts index 53725e0..4d91674 100644 --- a/apps/api/src/modules/notifications/scheduler.ts +++ b/apps/api/src/modules/notifications/scheduler.ts @@ -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 }; +} diff --git a/apps/api/src/modules/tracking/fast.routes.ts b/apps/api/src/modules/tracking/fast.routes.ts index d1529a9..750bee0 100644 --- a/apps/api/src/modules/tracking/fast.routes.ts +++ b/apps/api/src/modules/tracking/fast.routes.ts @@ -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, + }; + }); }; diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 620410b..e86e6d1 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -9,9 +9,10 @@ import { startNotificationScheduler } from './modules/notifications/scheduler.js async function start(): Promise { const app = await buildApp(); - const cronTask = startNotificationScheduler(app.prisma, app.log); + const cronHandles = startNotificationScheduler(app.prisma, app.log); app.addHook('onClose', async () => { - cronTask?.stop(); + cronHandles?.weekly.stop(); + cronHandles?.fasting.stop(); }); try { diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index e024b0a..bae9b32 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -136,6 +136,30 @@ "statCompleted": "Sessioni concluse", "statTotal": "Ore totali", "statSessions": "Sessioni totali", + "statWeekly": "Ore (7 gg)", + "statMonthly": "Ore (30 gg)", + "statCurrentStreak": "Streak attuale", + "statLongestStreak": "Streak record", + "statCompletionRate": "Tasso completamento", + "statStreakValue": "{n, plural, =0 {0 giorni} one {# giorno} other {# giorni}}", + "symptomsTitle": "Come ti senti?", + "symptomsHint": "Aggiorna i sintomi: ti aiuta a capire come reagisci a questo protocollo.", + "symptomHeadache": "Mal di testa", + "symptomEnergy": "Energia", + "symptomHunger": "Fame", + "symptomClarity": "Lucidità", + "symptomOther": "Altre note", + "symptomOtherPlaceholder": "Note libere…", + "symptomsSave": "Salva sintomi", + "symptomsSaved": "Salvato.", + "pauseEyebrow": "Modalità giorno libero", + "pauseUntil": "Reminder in pausa fino a {time}.", + "pauseToday": "Pausa per oggi", + "pauseResume": "Riprendi adesso", + "breakFastShow": "Suggerimenti per riprendere a mangiare", + "breakFastHide": "Nascondi suggerimenti", + "breakFastEmpty": "Nessuna ricetta disponibile in questo momento.", + "working": "Attendere…", "status": { "IN_PROGRESS": "In corso", "COMPLETED": "Completata", diff --git a/apps/web/src/app/[locale]/fasting/actions.ts b/apps/web/src/app/[locale]/fasting/actions.ts index 1e42e6d..e118249 100644 --- a/apps/web/src/app/[locale]/fasting/actions.ts +++ b/apps/web/src/app/[locale]/fasting/actions.ts @@ -25,6 +25,56 @@ function cookieHeader(): string { return headers().get('cookie') ?? ''; } +export interface BreakFastSuggestion { + id: string; + name: string; + kcal: number; + proteinG: number; + prepMinutes: number; + description: string | null; +} + +export async function fetchBreakFastSuggestions( + fastEventId: string, +): Promise { + 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 { + 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 { const res = await fetch(`${API_URL}/me/fast-events`, { headers: { cookie: cookieHeader() }, @@ -62,6 +112,32 @@ export async function startFast(input: FastEventStartInput): Promise { + const res = await fetch(`${API_URL}/me/fast-events/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', cookie: cookieHeader() }, + body: JSON.stringify({ symptoms }), + cache: 'no-store', + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + return { ok: false, error: body.error ?? `api_error_${res.status}` }; + } + revalidatePath('/fasting'); + return { ok: true }; +} + export type EndResult = { ok: true } | { ok: false; error: string }; export async function endFast(id: string, abort: boolean = false): Promise { diff --git a/apps/web/src/app/[locale]/fasting/break-fast-suggestions.tsx b/apps/web/src/app/[locale]/fasting/break-fast-suggestions.tsx new file mode 100644 index 0000000..c890bf1 --- /dev/null +++ b/apps/web/src/app/[locale]/fasting/break-fast-suggestions.tsx @@ -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(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 ( +
+ + {open && items ? ( +
+ {items.length === 0 ? ( +

{t('breakFastEmpty')}

+ ) : ( +
    + {items.map((s) => ( +
  • + + {s.name} + + + {Math.round(s.kcal)} kcal · {s.prepMinutes} min + +
  • + ))} +
+ )} +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/app/[locale]/fasting/fast-history.tsx b/apps/web/src/app/[locale]/fasting/fast-history.tsx index 128508a..b7cd81f 100644 --- a/apps/web/src/app/[locale]/fasting/fast-history.tsx +++ b/apps/web/src/app/[locale]/fasting/fast-history.tsx @@ -1,6 +1,8 @@ +import { computeFastingStats } from '@ketopath/shared'; import { useTranslations } from 'next-intl'; import type { FastEventRow } from './actions'; +import { BreakFastSuggestionsBlock } from './break-fast-suggestions'; const PROTOCOL_LABEL: Record = { FOURTEEN_TEN: '14:10', @@ -44,43 +46,54 @@ export function FastHistory({ events }: { events: FastEventRow[] }) { ); } - const completed = events.filter((e) => e.status === 'COMPLETED'); - const totalHours = - completed.reduce( - (sum, e) => - sum + ((e.endedAt ? new Date(e.endedAt).getTime() : 0) - new Date(e.startedAt).getTime()), - 0, - ) / 3_600_000; + const stats = computeFastingStats(events); return (
-
- - - +
+ + + + +
    - {events.slice(0, 12).map((e) => ( -
  • - - {PROTOCOL_LABEL[e.protocol]} - -
    -

    - {formatDate(e.startedAt)} -

    -

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

    -
    -

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

    -
  • - ))} + {events.slice(0, 12).map((e) => { + const hours = e.endedAt + ? (new Date(e.endedAt).getTime() - new Date(e.startedAt).getTime()) / 3_600_000 + : 0; + const isLongFast = e.status === 'COMPLETED' && hours >= 24; + return ( +
  • +
    + + {PROTOCOL_LABEL[e.protocol]} + +
    +

    + {formatDate(e.startedAt)} +

    +

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

    +
    +

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

    +
    + {isLongFast ? : null} +
  • + ); + })}
); diff --git a/apps/web/src/app/[locale]/fasting/fast-timer.tsx b/apps/web/src/app/[locale]/fasting/fast-timer.tsx index cb8ba7f..28f7c99 100644 --- a/apps/web/src/app/[locale]/fasting/fast-timer.tsx +++ b/apps/web/src/app/[locale]/fasting/fast-timer.tsx @@ -13,7 +13,7 @@ import { SelectValue, } from '@/components/ui/select'; -import { endFast, startFast, type FastEventRow } from './actions'; +import { endFast, startFast, updateFastSymptoms, type FastEventRow } from './actions'; type Protocol = (typeof FASTING_PROTOCOLS)[number]; @@ -107,18 +107,21 @@ function ActiveTimer({ event }: { event: FastEventRow }) {
-