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:
1 parent
60a3d072f6
commit
4633f42bc5
11 files changed
+351
-53
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user