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.