From 4633f42bc56263c4abacf60d3c5a0c98bfbfe517 Mon Sep 17 00:00:00 2001 From: luciano Date: Thu, 30 Apr 2026 00:35:35 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=C2=A76=20quick=20wins=20(Gruppo=20A)?= =?UTF-8?q?=20=E2=80=94=205=20quality-of-life=20che=20chiudono=20gap=20use?= =?UTF-8?q?r-flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A1. Notifiche per-pasto (PRD §6.3) - NotificationSettings.mealReminders (default off) - 3 ScheduledTask aggiuntivi: 13:00 pranzo, 16:00 spuntino, 19:00 cena Europe/Rome - runMealReminderJob: skip se utente in pausa, altrimenti push "Ora di pranzo: vedi cosa c'è nel piano di oggi" → /plan - Toggle in /profile sezione notifiche A2. Auto-start digiuno a fine cena (PRD §6.3) - POST /me/meal-plans/slots/:id/consumed: se è il "pasto finale" del giorno (CENA o ultimo dello schedule IF) e l'utente ha preferences. fastingProtocol senza FastEvent IN_PROGRESS → crea automaticamente un nuovo digiuno col protocollo preferito - isMealLastOfDay helper (rank-based: COL=0, PRA=1, SPU=2, CENA=3) - Risposta include autoStartedFast: { id } | null A3. Riassunto piano nel done step onboarding (PRD §6.1.9) - StepDone fetcha il profilo dopo regenerate - Card 4-colonne con Fase corrente + durata, BMR, TDEE, aderenza target - i18n: phaseLabel + phaseDuration A4. "% del percorso" in /tracking (PRD §6.4.5) - WeightHistory accetta startKg, calcola (start-current)/(start-goal) clampato 0-100 - 4° card nel summary: "Del percorso · 65%" A5. Lista spesa: spuntati in fondo + totale rimanente (PRD §6.5.4-5) - ShoppingChecklist sort items con consumed=true in fondo, alfabetico tra non-spuntati - "Restano €X" mostrato accanto al "n di total" — solo non-spuntati i18n: namespace Notifications, Onboarding, Tracking, Shopping estesi. 81/81 unit test verdi. Lint, typecheck verdi. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/modules/notifications/scheduler.ts | 95 ++++++++++++++- apps/api/src/modules/plan/plan.routes.ts | 65 +++++++++- apps/api/src/server.ts | 1 + apps/web/messages/it.json | 21 +++- .../[locale]/onboarding/onboarding-flow.tsx | 62 +++++++++- .../[locale]/profile/notifications-actions.ts | 4 +- .../[locale]/profile/notifications-panel.tsx | 13 +- .../[locale]/shopping/shopping-checklist.tsx | 113 ++++++++++++------ apps/web/src/app/[locale]/tracking/page.tsx | 4 +- .../app/[locale]/tracking/weight-history.tsx | 23 +++- packages/shared/src/notifications/schema.ts | 3 + 11 files changed, 351 insertions(+), 53 deletions(-) diff --git a/apps/api/src/modules/notifications/scheduler.ts b/apps/api/src/modules/notifications/scheduler.ts index 4d91674..eaff2b6 100644 --- a/apps/api/src/modules/notifications/scheduler.ts +++ b/apps/api/src/modules/notifications/scheduler.ts @@ -18,10 +18,15 @@ import { pushIsEnabled, sendToDevice } from './sender.js'; const WEEKLY_CRON = '0 9 * * 1'; // lunedì alle 09:00 const FASTING_CRON = '*/5 * * * *'; // ogni 5 minuti +// PRD §6.3 — orari fissi pranzo/spuntino/cena +const LUNCH_CRON = '0 13 * * *'; +const SNACK_CRON = '0 16 * * *'; +const DINNER_CRON = '0 19 * * *'; export interface SchedulerHandles { weekly: ScheduledTask; fasting: ScheduledTask; + meals: ScheduledTask[]; } export function startNotificationScheduler( @@ -48,10 +53,24 @@ export function startNotificationScheduler( }, { timezone: 'Europe/Rome' }, ); - log.info( - '[notifications] cron scheduled — weekly weigh-in (Mon 09:00) + fasting milestones (every 5 min)', + const meals: ScheduledTask[] = [ + [LUNCH_CRON, 'PRANZO'] as const, + [SNACK_CRON, 'SPUNTINO'] as const, + [DINNER_CRON, 'CENA'] as const, + ].map(([expr, slot]) => + // eslint-disable-next-line import/no-named-as-default-member + cron.schedule( + expr, + () => { + void runMealReminderJob(prisma, log, slot); + }, + { timezone: 'Europe/Rome' }, + ), ); - return { weekly, fasting }; + log.info( + '[notifications] cron scheduled — weekly weigh-in (Mon 09:00), fasting milestones (every 5 min), meal reminders (13/16/19 daily)', + ); + return { weekly, fasting, meals }; } export async function runWeeklyWeighInJob( @@ -219,3 +238,73 @@ export async function runFastingMilestonesJob( } return { checked: active.length, sent, expired }; } + +// PRD §6.3 — promemoria pasti agli orari italiani standard. +// Ignoriamo gli utenti in pausa (fastingPausedUntil futuro). Per gli utenti +// con fastingProtocol attivo che esclude un pasto (es. 16:8 senza colazione), +// se il pasto richiesto è 0% nello share del giorno corrente, saltiamo. +const MEAL_COPY: Record<'PRANZO' | 'SPUNTINO' | 'CENA', { title: string; body: string }> = { + PRANZO: { + title: 'Ora di pranzo', + body: "Vedi cosa c'è nel piano di oggi.", + }, + SPUNTINO: { + title: 'Spuntino', + body: "Una pausa veloce: dai un'occhiata al piano.", + }, + CENA: { + title: 'È quasi ora di cena', + body: 'Apri KetoPath per la ricetta di stasera.', + }, +}; + +export async function runMealReminderJob( + prisma: ExtendedPrismaClient, + log: FastifyBaseLogger, + meal: 'PRANZO' | 'SPUNTINO' | 'CENA', +): Promise<{ candidates: number; sent: number; expired: number }> { + const candidates = await prisma.user.findMany({ + where: { + preferences: { isNot: null }, + deviceTokens: { some: {} }, + }, + select: { + id: true, + fastingPausedUntil: true, + preferences: { select: { notificationSettings: true } }, + deviceTokens: true, + }, + }); + + const now = Date.now(); + let sent = 0; + let expired = 0; + for (const u of candidates) { + const settings = readSettings(u.preferences?.notificationSettings ?? null); + if (!settings.mealReminders) continue; + if (u.fastingPausedUntil && u.fastingPausedUntil.getTime() > now) continue; + + const copy = MEAL_COPY[meal]; + for (const token of u.deviceTokens) { + const r = await sendToDevice(token, { + title: copy.title, + body: copy.body, + url: '/plan', + }); + if (r.ok) sent++; + if (r.expired) { + expired++; + await prisma.deviceToken.delete({ where: { id: token.id } }).catch(() => { + /* ignora race */ + }); + } + } + } + if (sent > 0 || expired > 0) { + log.info( + { meal, candidates: candidates.length, sent, expired }, + '[notifications] meal reminder job', + ); + } + return { candidates: candidates.length, sent, expired }; +} diff --git a/apps/api/src/modules/plan/plan.routes.ts b/apps/api/src/modules/plan/plan.routes.ts index 7a47c28..1c85493 100644 --- a/apps/api/src/modules/plan/plan.routes.ts +++ b/apps/api/src/modules/plan/plan.routes.ts @@ -1,4 +1,5 @@ import { + PROTOCOL_DEFAULT_MINUTES, bmrAdjustForDietHistory, bmrAdjustmentForConditions, calculateBmr, @@ -41,6 +42,24 @@ function phaseToInt(p: string): 1 | 2 | 3 { return 3; } +// PRD §6.3 — il "pasto finale" della giornata determina quando far partire +// auto il digiuno. Ordine canonico: COLAZIONE, PRANZO, SPUNTINO, CENA. Se uno +// di questi manca dal giorno (per protocollo IF), l'ultimo presente conta. +const MEAL_ORDER_MAP: Record = { + COLAZIONE: 0, + PRANZO: 1, + SPUNTINO: 2, + CENA: 3, +}; + +function isMealLastOfDay(meal: string, daySlots: ReadonlyArray<{ meal: string }>): boolean { + const myRank = MEAL_ORDER_MAP[meal] ?? -1; + const otherRanks = daySlots + .filter((s) => s.meal !== meal) + .map((s) => MEAL_ORDER_MAP[s.meal] ?? -1); + return otherRanks.every((r) => r < myRank); +} + function parseConditions(raw: string | null): string[] { if (!raw) return []; try { @@ -284,6 +303,9 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { ); // PRD §5.2 — aderenza: l'utente segna lo slot come consumato. + // PRD §6.3 — se è l'ultimo pasto della giornata e l'utente ha un protocollo + // di digiuno attivo nelle preferenze, e nessuna sessione è in corso, parte + // automaticamente un nuovo digiuno. fastify.post( '/me/meal-plans/slots/:slotId/consumed', { preHandler: requireAuth() }, @@ -295,7 +317,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { const slot = await fastify.prisma.mealSlot.findFirst({ where: { id: slotId, plan: { userId } }, - select: { id: true, consumed: true }, + select: { id: true, consumed: true, dayOfWeek: true, meal: true, planId: true }, }); if (!slot) return reply.code(404).send({ error: 'slot_not_found' }); @@ -305,7 +327,46 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { data: { consumed: next, consumedAt: next ? new Date() : null }, select: { id: true, consumed: true, consumedAt: true }, }); - return { slot: updated }; + + // Auto-start digiuno se è l'ultimo pasto del giorno consumato. + let autoStartedFast: { id: string } | null = null; + if (next) { + const sameDaySlots = await fastify.prisma.mealSlot.findMany({ + where: { planId: slot.planId, dayOfWeek: slot.dayOfWeek }, + select: { id: true, meal: true }, + orderBy: { meal: 'asc' }, + }); + const isLastOfDay = isMealLastOfDay(slot.meal, sameDaySlots); + if (isLastOfDay) { + const prefs = await fastify.prisma.preferences.findUnique({ + where: { userId }, + select: { fastingProtocol: true }, + }); + if (prefs?.fastingProtocol) { + const active = await fastify.prisma.fastEvent.findFirst({ + where: { userId, status: 'IN_PROGRESS' }, + select: { id: true }, + }); + if (!active) { + const minutes = + PROTOCOL_DEFAULT_MINUTES[ + prefs.fastingProtocol as keyof typeof PROTOCOL_DEFAULT_MINUTES + ] ?? 16 * 60; + const created = await fastify.prisma.fastEvent.create({ + data: { + userId, + protocol: prefs.fastingProtocol, + startedAt: new Date(), + targetDuration: minutes, + }, + }); + autoStartedFast = { id: created.id }; + } + } + } + } + + return { slot: updated, autoStartedFast }; }, ); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index e86e6d1..ffbc38d 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -13,6 +13,7 @@ async function start(): Promise { app.addHook('onClose', async () => { cronHandles?.weekly.stop(); cronHandles?.fasting.stop(); + cronHandles?.meals.forEach((m) => m.stop()); }); try { diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index e286ef8..aa93ccf 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -189,6 +189,7 @@ "delta": "Variazione", "emptyHistory": "Nessuna rilevazione registrata. Inserisci la prima a destra: la tendenza apparirà dopo due o tre pesate.", "weeklyTrend": "Trend settimanale", + "journey": "Del percorso", "projection": "Al ritmo attuale raggiungerai {kg} kg il {date}.", "alertEyebrow": "Soglia di allarme", "alertMessage": "Il tuo peso è {current} kg, sopra la soglia di {threshold} kg che hai impostato.", @@ -280,6 +281,21 @@ "notificationsBlocked": "Hai bloccato le notifiche per questo sito. Le potrai riattivare dalle impostazioni del browser.", "notificationsUnsupported": "Il tuo browser non supporta le notifiche push. Su iOS apri prima KetoPath dalla Home.", "notificationsError": "Non è stato possibile attivarle. Riprova più tardi dal tuo profilo.", + "summaryPhase": "Fase corrente", + "summaryBmr": "BMR", + "summaryTdee": "Fabbisogno", + "summaryAdherence": "Aderenza target", + "summaryAdherenceHint": "Per fase 1", + "phaseLabel": { + "INTENSIVE": "Intensiva", + "TRANSITION": "Transizione", + "MAINTENANCE": "Mantenimento" + }, + "phaseDuration": { + "INTENSIVE": "30-45 giorni", + "TRANSITION": "30-60 giorni", + "MAINTENANCE": "Per sempre" + }, "doneHeading": "Tutto pronto.", "doneHeadingGoal": { "LOSE": "Si parte. Un passo alla volta.", @@ -387,7 +403,9 @@ "weeklyWeighInLabel": "Promemoria pesata settimanale", "weeklyWeighInHint": "Lunedì alle 09:00 ti ricordiamo la pesata della settimana.", "fastingMilestonesLabel": "Tappe del digiuno", - "fastingMilestonesHint": "Ti avvisiamo all'ingresso in chetogenesi e all'autofagia. Disponibile a breve.", + "fastingMilestonesHint": "Idratazione ogni 2h, alert elettroliti oltre le 18h, 30 min prima della fine.", + "mealRemindersLabel": "Promemoria pasti", + "mealRemindersHint": "Pranzo alle 13:00, spuntino alle 16:00, cena alle 19:00 — solo nei giorni non in pausa.", "registeredDevices": "Dispositivi registrati", "subscribed": "Notifiche attivate.", "unsubscribed": "Notifiche disattivate su questo dispositivo.", @@ -418,6 +436,7 @@ "totalLabel": "Totale articoli", "estimatedCost": "Spesa stimata", "checked": "{n} di {total}", + "remainingCost": "Restano", "fromRecipes": "Per: {names}", "categoryLabel": { "verdura": "Verdura", diff --git a/apps/web/src/app/[locale]/onboarding/onboarding-flow.tsx b/apps/web/src/app/[locale]/onboarding/onboarding-flow.tsx index 85674ee..2b727eb 100644 --- a/apps/web/src/app/[locale]/onboarding/onboarding-flow.tsx +++ b/apps/web/src/app/[locale]/onboarding/onboarding-flow.tsx @@ -23,7 +23,7 @@ import { Button } from '@/components/ui/button'; import { pushSupported, subscribe, getCurrentSubscription } from '@/lib/notifications/push-client'; import { regeneratePlan } from '../plan/actions'; -import { saveConditions } from '../profile/actions'; +import { fetchProfile, saveConditions } from '../profile/actions'; import { savePreferences, type PreferencesView } from '../profile/preferences-actions'; import { ProfileForm as ProfileFormComponent } from '../profile/profile-form'; import type { ProfileForm } from '../profile/profile-form'; @@ -457,18 +457,39 @@ function StepNotifications({ onContinue }: { onContinue: () => void }) { ); } +interface PlanSummary { + bmr: number; + tdee: number; + currentPhase: string; +} + function StepDone({ goal }: { goal: Goal | null }) { const t = useTranslations('Onboarding'); const router = useRouter(); const [planStatus, setPlanStatus] = useState<'idle' | 'pending' | 'ready' | 'error'>('idle'); const [iosInstallHint, setIosInstallHint] = useState(false); + const [planSummary, setPlanSummary] = useState(null); // Genera il primo piano se non esiste — best-effort. useEffect(() => { if (planStatus !== 'idle') return; setPlanStatus('pending'); regeneratePlan() - .then(() => setPlanStatus('ready')) + .then(async () => { + setPlanStatus('ready'); + // Carica il summary profilo per il riassunto + const profile = (await fetchProfile()) as { + derived?: { bmr: number; tdee: number }; + currentPhase?: string; + } | null; + if (profile?.derived) { + setPlanSummary({ + bmr: profile.derived.bmr, + tdee: profile.derived.tdee, + currentPhase: profile.currentPhase ?? 'INTENSIVE', + }); + } + }) .catch(() => setPlanStatus('error')); }, [planStatus]); @@ -499,6 +520,43 @@ function StepDone({ goal }: { goal: Goal | null }) {

+ {planSummary ? ( +
+
+

{t('summaryPhase')}

+

+ {t(`phaseLabel.${planSummary.currentPhase}`)} +

+

+ {t(`phaseDuration.${planSummary.currentPhase}`)} +

+
+
+

{t('summaryBmr')}

+

+ {planSummary.bmr} + kcal +

+
+
+

{t('summaryTdee')}

+

+ {planSummary.tdee} + kcal +

+
+
+

{t('summaryAdherence')}

+

+ {planSummary.currentPhase === 'INTENSIVE' ? '90%' : '85%'} +

+

+ {t('summaryAdherenceHint')} +

+
+
+ ) : null} +
} export async function updateNotificationSettings( - patch: Partial<{ weeklyWeighIn: boolean; fastingMilestones: boolean }>, + patch: Partial<{ weeklyWeighIn: boolean; fastingMilestones: boolean; mealReminders: boolean }>, ): Promise { const res = await fetch(`${API_URL}/me/notifications/settings`, { method: 'PATCH', diff --git a/apps/web/src/app/[locale]/profile/notifications-panel.tsx b/apps/web/src/app/[locale]/profile/notifications-panel.tsx index 5985f47..ddea263 100644 --- a/apps/web/src/app/[locale]/profile/notifications-panel.tsx +++ b/apps/web/src/app/[locale]/profile/notifications-panel.tsx @@ -105,7 +105,10 @@ export function NotificationsPanel({ initial }: { initial: NotificationConfig | }); } - function handleToggle(key: 'weeklyWeighIn' | 'fastingMilestones', value: boolean): void { + function handleToggle( + key: 'weeklyWeighIn' | 'fastingMilestones' | 'mealReminders', + value: boolean, + ): void { startTransition(async () => { const result = await updateNotificationSettings({ [key]: value }); if (!result.ok) { @@ -162,7 +165,13 @@ export function NotificationsPanel({ initial }: { initial: NotificationConfig | checked={cfg.settings.fastingMilestones} onChange={(v) => handleToggle('fastingMilestones', v)} disabled={pending || !pushReady} - comingSoon + /> + handleToggle('mealReminders', v)} + disabled={pending || !pushReady} />
diff --git a/apps/web/src/app/[locale]/shopping/shopping-checklist.tsx b/apps/web/src/app/[locale]/shopping/shopping-checklist.tsx index 9b1583f..a094518 100644 --- a/apps/web/src/app/[locale]/shopping/shopping-checklist.tsx +++ b/apps/web/src/app/[locale]/shopping/shopping-checklist.tsx @@ -64,6 +64,24 @@ export function ShoppingChecklist({ list }: { list: ShoppingList }) { ); const checkedCount = useMemo(() => Object.values(checked).filter(Boolean).length, [checked]); + // PRD §6.5 — totale stimato sui rimanenti (non spuntati). Heuristica + // identica a shopping/page.tsx: g/ml ÷1000, altrimenti ×1. + const remainingCost = useMemo(() => { + let total = 0; + for (const g of list.groups) { + for (const it of g.items) { + if (checked[lineKey(it)]) continue; + if (it.priceAvgEur == null) continue; + if (it.unit === 'g' || it.unit === 'ml') { + total += (it.quantity / 1000) * it.priceAvgEur; + } else { + total += it.quantity * it.priceAvgEur; + } + } + } + return total; + }, [list.groups, checked]); + function toggle(line: ShoppingLine): void { const key = lineKey(line); setChecked((prev) => ({ ...prev, [key]: !prev[key] })); @@ -71,9 +89,18 @@ export function ShoppingChecklist({ list }: { list: ShoppingList }) { return (
-

- {t('checked', { n: checkedCount, total: totalCount })} -

+
+

+ {t('checked', { n: checkedCount, total: totalCount })} +

+ {remainingCost > 0 ? ( +

+ {t('remainingCost')}{' '} + €{' '} + {remainingCost.toFixed(2)} +

+ ) : null} +
{list.groups.map((group, groupIdx) => (
    - {group.items.map((line) => { - const key = lineKey(line); - const isChecked = !!checked[key]; - return ( -
  • - -
  • - ); - })} + + + ); + })}
))} diff --git a/apps/web/src/app/[locale]/tracking/page.tsx b/apps/web/src/app/[locale]/tracking/page.tsx index cfd624f..199a7fd 100644 --- a/apps/web/src/app/[locale]/tracking/page.tsx +++ b/apps/web/src/app/[locale]/tracking/page.tsx @@ -15,6 +15,7 @@ import { WeightHistory } from './weight-history'; type ProfileLike = { weightGoalKg?: number; + weightStartKg?: number; alertWeightKg?: number | null; } | null; @@ -57,6 +58,7 @@ function TrackingPageContent({ const t = useTranslations('Tracking'); const latest = entries[0] ?? null; const goalKg = typeof profile?.weightGoalKg === 'number' ? profile.weightGoalKg : null; + const startKg = typeof profile?.weightStartKg === 'number' ? profile.weightStartKg : null; const alertKg = typeof profile?.alertWeightKg === 'number' ? profile.alertWeightKg : null; const alertActive = alertKg != null && latest != null && latest.weightKg >= alertKg; @@ -110,7 +112,7 @@ function TrackingPageContent({
diff --git a/apps/web/src/app/[locale]/tracking/weight-history.tsx b/apps/web/src/app/[locale]/tracking/weight-history.tsx index d65bb62..07d0dfb 100644 --- a/apps/web/src/app/[locale]/tracking/weight-history.tsx +++ b/apps/web/src/app/[locale]/tracking/weight-history.tsx @@ -67,9 +67,11 @@ const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', { export function WeightHistory({ entries, goalKg, + startKg, }: { entries: WeightEntryRow[]; goalKg?: number | null; + startKg?: number | null; }) { const t = useTranslations('Tracking'); if (entries.length === 0) { @@ -98,6 +100,16 @@ export function WeightHistory({ }) : null; + // PRD §6.4 — % del percorso. (start - current) / (start - goal). + // Se start == goal o sign mismatch, ritorna null. + const journeyPercent = + startKg != null && goalKg != null && startKg !== goalKg + ? Math.max( + 0, + Math.min(100, Math.round(((startKg - newest.weightKg) / (startKg - goalKg)) * 100)), + ) + : null; + return (
@@ -108,7 +120,7 @@ export function WeightHistory({
-
+

{t('current')}

@@ -146,6 +158,15 @@ export function WeightHistory({

) : null} + {journeyPercent != null ? ( +
+

{t('journey')}

+

+ {journeyPercent} + % +

+
+ ) : null}
{projectedDate && goalKg != null ? ( diff --git a/packages/shared/src/notifications/schema.ts b/packages/shared/src/notifications/schema.ts index eaeb0c3..3dd0327 100644 --- a/packages/shared/src/notifications/schema.ts +++ b/packages/shared/src/notifications/schema.ts @@ -5,6 +5,8 @@ export const notificationSettingsSchema = z .object({ weeklyWeighIn: z.boolean().default(false), fastingMilestones: z.boolean().default(false), + // PRD §6.3 — promemoria a 13:00/16:00/19:00 per pranzo/spuntino/cena. + mealReminders: z.boolean().default(false), }) .strict(); @@ -13,6 +15,7 @@ export type NotificationSettings = z.infer; export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = { weeklyWeighIn: false, fastingMilestones: false, + mealReminders: false, }; // Body inviato dal client al backend per registrare una subscription web.