feat: §6 quick wins (Gruppo A) — 5 quality-of-life che chiudono gap user-flow

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) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.7 committed 2026-04-30 00:35:35 +02:00
1 parent 60a3d072f6
commit 4633f42bc5
11 files changed
+351 -53

No files matched your search

+63 -2
View File
@@ -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<string, number> = {
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 };
},
);