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 WEEKLY_CRON = '0 9 * * 1'; // lunedì alle 09:00
|
||||||
const FASTING_CRON = '*/5 * * * *'; // ogni 5 minuti
|
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 {
|
export interface SchedulerHandles {
|
||||||
weekly: ScheduledTask;
|
weekly: ScheduledTask;
|
||||||
fasting: ScheduledTask;
|
fasting: ScheduledTask;
|
||||||
|
meals: ScheduledTask[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startNotificationScheduler(
|
export function startNotificationScheduler(
|
||||||
@@ -48,10 +53,24 @@ export function startNotificationScheduler(
|
|||||||
},
|
},
|
||||||
{ timezone: 'Europe/Rome' },
|
{ timezone: 'Europe/Rome' },
|
||||||
);
|
);
|
||||||
log.info(
|
const meals: ScheduledTask[] = [
|
||||||
'[notifications] cron scheduled — weekly weigh-in (Mon 09:00) + fasting milestones (every 5 min)',
|
[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(
|
export async function runWeeklyWeighInJob(
|
||||||
@@ -219,3 +238,73 @@ export async function runFastingMilestonesJob(
|
|||||||
}
|
}
|
||||||
return { checked: active.length, sent, expired };
|
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 {
|
import {
|
||||||
|
PROTOCOL_DEFAULT_MINUTES,
|
||||||
bmrAdjustForDietHistory,
|
bmrAdjustForDietHistory,
|
||||||
bmrAdjustmentForConditions,
|
bmrAdjustmentForConditions,
|
||||||
calculateBmr,
|
calculateBmr,
|
||||||
@@ -41,6 +42,24 @@ function phaseToInt(p: string): 1 | 2 | 3 {
|
|||||||
return 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[] {
|
function parseConditions(raw: string | null): string[] {
|
||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
try {
|
try {
|
||||||
@@ -284,6 +303,9 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// PRD §5.2 — aderenza: l'utente segna lo slot come consumato.
|
// 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(
|
fastify.post(
|
||||||
'/me/meal-plans/slots/:slotId/consumed',
|
'/me/meal-plans/slots/:slotId/consumed',
|
||||||
{ preHandler: requireAuth() },
|
{ preHandler: requireAuth() },
|
||||||
@@ -295,7 +317,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
const slot = await fastify.prisma.mealSlot.findFirst({
|
const slot = await fastify.prisma.mealSlot.findFirst({
|
||||||
where: { id: slotId, plan: { userId } },
|
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' });
|
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 },
|
data: { consumed: next, consumedAt: next ? new Date() : null },
|
||||||
select: { id: true, consumed: true, consumedAt: true },
|
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 };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ async function start(): Promise<void> {
|
|||||||
app.addHook('onClose', async () => {
|
app.addHook('onClose', async () => {
|
||||||
cronHandles?.weekly.stop();
|
cronHandles?.weekly.stop();
|
||||||
cronHandles?.fasting.stop();
|
cronHandles?.fasting.stop();
|
||||||
|
cronHandles?.meals.forEach((m) => m.stop());
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -189,6 +189,7 @@
|
|||||||
"delta": "Variazione",
|
"delta": "Variazione",
|
||||||
"emptyHistory": "Nessuna rilevazione registrata. Inserisci la prima a destra: la tendenza apparirà dopo due o tre pesate.",
|
"emptyHistory": "Nessuna rilevazione registrata. Inserisci la prima a destra: la tendenza apparirà dopo due o tre pesate.",
|
||||||
"weeklyTrend": "Trend settimanale",
|
"weeklyTrend": "Trend settimanale",
|
||||||
|
"journey": "Del percorso",
|
||||||
"projection": "Al ritmo attuale raggiungerai {kg} kg il {date}.",
|
"projection": "Al ritmo attuale raggiungerai {kg} kg il {date}.",
|
||||||
"alertEyebrow": "Soglia di allarme",
|
"alertEyebrow": "Soglia di allarme",
|
||||||
"alertMessage": "Il tuo peso è {current} kg, sopra la soglia di {threshold} kg che hai impostato.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"doneHeading": "Tutto pronto.",
|
||||||
"doneHeadingGoal": {
|
"doneHeadingGoal": {
|
||||||
"LOSE": "Si parte. Un passo alla volta.",
|
"LOSE": "Si parte. Un passo alla volta.",
|
||||||
@@ -387,7 +403,9 @@
|
|||||||
"weeklyWeighInLabel": "Promemoria pesata settimanale",
|
"weeklyWeighInLabel": "Promemoria pesata settimanale",
|
||||||
"weeklyWeighInHint": "Lunedì alle 09:00 ti ricordiamo la pesata della settimana.",
|
"weeklyWeighInHint": "Lunedì alle 09:00 ti ricordiamo la pesata della settimana.",
|
||||||
"fastingMilestonesLabel": "Tappe del digiuno",
|
"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",
|
"registeredDevices": "Dispositivi registrati",
|
||||||
"subscribed": "Notifiche attivate.",
|
"subscribed": "Notifiche attivate.",
|
||||||
"unsubscribed": "Notifiche disattivate su questo dispositivo.",
|
"unsubscribed": "Notifiche disattivate su questo dispositivo.",
|
||||||
@@ -418,6 +436,7 @@
|
|||||||
"totalLabel": "Totale articoli",
|
"totalLabel": "Totale articoli",
|
||||||
"estimatedCost": "Spesa stimata",
|
"estimatedCost": "Spesa stimata",
|
||||||
"checked": "{n} di {total}",
|
"checked": "{n} di {total}",
|
||||||
|
"remainingCost": "Restano",
|
||||||
"fromRecipes": "Per: {names}",
|
"fromRecipes": "Per: {names}",
|
||||||
"categoryLabel": {
|
"categoryLabel": {
|
||||||
"verdura": "Verdura",
|
"verdura": "Verdura",
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { pushSupported, subscribe, getCurrentSubscription } from '@/lib/notifications/push-client';
|
import { pushSupported, subscribe, getCurrentSubscription } from '@/lib/notifications/push-client';
|
||||||
|
|
||||||
import { regeneratePlan } from '../plan/actions';
|
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 { savePreferences, type PreferencesView } from '../profile/preferences-actions';
|
||||||
import { ProfileForm as ProfileFormComponent } from '../profile/profile-form';
|
import { ProfileForm as ProfileFormComponent } from '../profile/profile-form';
|
||||||
import type { ProfileForm } 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 }) {
|
function StepDone({ goal }: { goal: Goal | null }) {
|
||||||
const t = useTranslations('Onboarding');
|
const t = useTranslations('Onboarding');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [planStatus, setPlanStatus] = useState<'idle' | 'pending' | 'ready' | 'error'>('idle');
|
const [planStatus, setPlanStatus] = useState<'idle' | 'pending' | 'ready' | 'error'>('idle');
|
||||||
const [iosInstallHint, setIosInstallHint] = useState(false);
|
const [iosInstallHint, setIosInstallHint] = useState(false);
|
||||||
|
const [planSummary, setPlanSummary] = useState<PlanSummary | null>(null);
|
||||||
|
|
||||||
// Genera il primo piano se non esiste — best-effort.
|
// Genera il primo piano se non esiste — best-effort.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (planStatus !== 'idle') return;
|
if (planStatus !== 'idle') return;
|
||||||
setPlanStatus('pending');
|
setPlanStatus('pending');
|
||||||
regeneratePlan()
|
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'));
|
.catch(() => setPlanStatus('error'));
|
||||||
}, [planStatus]);
|
}, [planStatus]);
|
||||||
|
|
||||||
@@ -499,6 +520,43 @@ function StepDone({ goal }: { goal: Goal | null }) {
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{planSummary ? (
|
||||||
|
<section className="border-ink/15 bg-carta-light/40 grid grid-cols-2 gap-x-8 gap-y-4 border p-5 sm:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<p className="editorial-eyebrow">{t('summaryPhase')}</p>
|
||||||
|
<p className="font-display text-pomodoro mt-2 text-2xl font-medium leading-tight">
|
||||||
|
{t(`phaseLabel.${planSummary.currentPhase}`)}
|
||||||
|
</p>
|
||||||
|
<p className="text-ink-soft font-mono text-[10px] uppercase tracking-widest">
|
||||||
|
{t(`phaseDuration.${planSummary.currentPhase}`)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="editorial-eyebrow">{t('summaryBmr')}</p>
|
||||||
|
<p className="text-ink mt-2 font-mono text-3xl font-medium tabular-nums leading-none">
|
||||||
|
{planSummary.bmr}
|
||||||
|
<span className="font-display text-ink-soft ml-1 text-xs italic">kcal</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="editorial-eyebrow">{t('summaryTdee')}</p>
|
||||||
|
<p className="text-ink mt-2 font-mono text-3xl font-medium tabular-nums leading-none">
|
||||||
|
{planSummary.tdee}
|
||||||
|
<span className="font-display text-ink-soft ml-1 text-xs italic">kcal</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="editorial-eyebrow">{t('summaryAdherence')}</p>
|
||||||
|
<p className="text-ink mt-2 font-mono text-3xl font-medium tabular-nums leading-none">
|
||||||
|
{planSummary.currentPhase === 'INTENSIVE' ? '90%' : '85%'}
|
||||||
|
</p>
|
||||||
|
<p className="text-ink-soft font-mono text-[10px] uppercase tracking-widest">
|
||||||
|
{t('summaryAdherenceHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<NextCard
|
<NextCard
|
||||||
eyebrow={t('nextPlanEyebrow')}
|
eyebrow={t('nextPlanEyebrow')}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export interface NotificationDevice {
|
|||||||
|
|
||||||
export interface NotificationConfig {
|
export interface NotificationConfig {
|
||||||
pushEnabled: boolean;
|
pushEnabled: boolean;
|
||||||
settings: { weeklyWeighIn: boolean; fastingMilestones: boolean };
|
settings: { weeklyWeighIn: boolean; fastingMilestones: boolean; mealReminders: boolean };
|
||||||
devices: NotificationDevice[];
|
devices: NotificationDevice[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ export async function unregisterDevice(deviceId: string): Promise<ActionResult>
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateNotificationSettings(
|
export async function updateNotificationSettings(
|
||||||
patch: Partial<{ weeklyWeighIn: boolean; fastingMilestones: boolean }>,
|
patch: Partial<{ weeklyWeighIn: boolean; fastingMilestones: boolean; mealReminders: boolean }>,
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult> {
|
||||||
const res = await fetch(`${API_URL}/me/notifications/settings`, {
|
const res = await fetch(`${API_URL}/me/notifications/settings`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
|||||||
@@ -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 () => {
|
startTransition(async () => {
|
||||||
const result = await updateNotificationSettings({ [key]: value });
|
const result = await updateNotificationSettings({ [key]: value });
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
@@ -162,7 +165,13 @@ export function NotificationsPanel({ initial }: { initial: NotificationConfig |
|
|||||||
checked={cfg.settings.fastingMilestones}
|
checked={cfg.settings.fastingMilestones}
|
||||||
onChange={(v) => handleToggle('fastingMilestones', v)}
|
onChange={(v) => handleToggle('fastingMilestones', v)}
|
||||||
disabled={pending || !pushReady}
|
disabled={pending || !pushReady}
|
||||||
comingSoon
|
/>
|
||||||
|
<ToggleRow
|
||||||
|
label={t('mealRemindersLabel')}
|
||||||
|
description={t('mealRemindersHint')}
|
||||||
|
checked={cfg.settings.mealReminders ?? false}
|
||||||
|
onChange={(v) => handleToggle('mealReminders', v)}
|
||||||
|
disabled={pending || !pushReady}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,24 @@ export function ShoppingChecklist({ list }: { list: ShoppingList }) {
|
|||||||
);
|
);
|
||||||
const checkedCount = useMemo(() => Object.values(checked).filter(Boolean).length, [checked]);
|
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 {
|
function toggle(line: ShoppingLine): void {
|
||||||
const key = lineKey(line);
|
const key = lineKey(line);
|
||||||
setChecked((prev) => ({ ...prev, [key]: !prev[key] }));
|
setChecked((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
@@ -71,9 +89,18 @@ export function ShoppingChecklist({ list }: { list: ShoppingList }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-12">
|
<div className="space-y-12">
|
||||||
<p className="font-display text-ink-soft text-base italic leading-snug">
|
<div className="flex flex-wrap items-baseline justify-between gap-4">
|
||||||
{t('checked', { n: checkedCount, total: totalCount })}
|
<p className="font-display text-ink-soft text-base italic leading-snug">
|
||||||
</p>
|
{t('checked', { n: checkedCount, total: totalCount })}
|
||||||
|
</p>
|
||||||
|
{remainingCost > 0 ? (
|
||||||
|
<p className="text-ink font-mono text-sm tabular-nums">
|
||||||
|
{t('remainingCost')}{' '}
|
||||||
|
<span className="font-display text-ink-soft text-xs italic">€</span>{' '}
|
||||||
|
{remainingCost.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
{list.groups.map((group, groupIdx) => (
|
{list.groups.map((group, groupIdx) => (
|
||||||
<section
|
<section
|
||||||
@@ -99,46 +126,54 @@ export function ShoppingChecklist({ list }: { list: ShoppingList }) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ul className="divide-ink/10 divide-y">
|
<ul className="divide-ink/10 divide-y">
|
||||||
{group.items.map((line) => {
|
{[...group.items]
|
||||||
const key = lineKey(line);
|
.sort((a, b) => {
|
||||||
const isChecked = !!checked[key];
|
// PRD §6.5 — gli articoli spuntati vanno in fondo.
|
||||||
return (
|
const ac = checked[lineKey(a)] ? 1 : 0;
|
||||||
<li key={key}>
|
const bc = checked[lineKey(b)] ? 1 : 0;
|
||||||
<button
|
if (ac !== bc) return ac - bc;
|
||||||
type="button"
|
return a.name.localeCompare(b.name, 'it');
|
||||||
onClick={() => toggle(line)}
|
})
|
||||||
aria-pressed={isChecked}
|
.map((line) => {
|
||||||
className="hover:bg-ink/5 group grid w-full grid-cols-[1.25rem_1fr_auto] items-baseline gap-4 py-4 pr-2 text-left transition-colors"
|
const key = lineKey(line);
|
||||||
>
|
const isChecked = !!checked[key];
|
||||||
<span
|
return (
|
||||||
aria-hidden
|
<li key={key}>
|
||||||
className={`border-ink h-3 w-3 translate-y-1 border-[1.5px] transition-colors ${
|
<button
|
||||||
isChecked ? 'bg-ink' : 'bg-transparent'
|
type="button"
|
||||||
}`}
|
onClick={() => toggle(line)}
|
||||||
/>
|
aria-pressed={isChecked}
|
||||||
<span className="flex flex-col gap-1">
|
className="hover:bg-ink/5 group grid w-full grid-cols-[1.25rem_1fr_auto] items-baseline gap-4 py-4 pr-2 text-left transition-colors"
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
className={`font-display text-ink text-lg leading-tight transition-colors ${
|
aria-hidden
|
||||||
isChecked ? 'text-ink-dim line-through' : ''
|
className={`border-ink h-3 w-3 translate-y-1 border-[1.5px] transition-colors ${
|
||||||
|
isChecked ? 'bg-ink' : 'bg-transparent'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="flex flex-col gap-1">
|
||||||
|
<span
|
||||||
|
className={`font-display text-ink text-lg leading-tight transition-colors ${
|
||||||
|
isChecked ? 'text-ink-dim line-through' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{line.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-ink-soft font-mono text-[10px] uppercase tracking-widest">
|
||||||
|
{t('fromRecipes', { names: line.recipes.join(' · ') })}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-ink font-mono text-sm tabular-nums transition-colors ${
|
||||||
|
isChecked ? 'text-ink-dim' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{line.name}
|
{formatQuantity(line.quantity)} {line.unit}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-ink-soft font-mono text-[10px] uppercase tracking-widest">
|
</button>
|
||||||
{t('fromRecipes', { names: line.recipes.join(' · ') })}
|
</li>
|
||||||
</span>
|
);
|
||||||
</span>
|
})}
|
||||||
<span
|
|
||||||
className={`text-ink font-mono text-sm tabular-nums transition-colors ${
|
|
||||||
isChecked ? 'text-ink-dim' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{formatQuantity(line.quantity)} {line.unit}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { WeightHistory } from './weight-history';
|
|||||||
|
|
||||||
type ProfileLike = {
|
type ProfileLike = {
|
||||||
weightGoalKg?: number;
|
weightGoalKg?: number;
|
||||||
|
weightStartKg?: number;
|
||||||
alertWeightKg?: number | null;
|
alertWeightKg?: number | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ function TrackingPageContent({
|
|||||||
const t = useTranslations('Tracking');
|
const t = useTranslations('Tracking');
|
||||||
const latest = entries[0] ?? null;
|
const latest = entries[0] ?? null;
|
||||||
const goalKg = typeof profile?.weightGoalKg === 'number' ? profile.weightGoalKg : 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 alertKg = typeof profile?.alertWeightKg === 'number' ? profile.alertWeightKg : null;
|
||||||
const alertActive = alertKg != null && latest != null && latest.weightKg >= alertKg;
|
const alertActive = alertKg != null && latest != null && latest.weightKg >= alertKg;
|
||||||
|
|
||||||
@@ -110,7 +112,7 @@ function TrackingPageContent({
|
|||||||
</section>
|
</section>
|
||||||
<aside className="md:border-ink/15 animate-fade-up [animation-delay:420ms] md:col-span-5 md:border-l md:pl-10">
|
<aside className="md:border-ink/15 animate-fade-up [animation-delay:420ms] md:col-span-5 md:border-l md:pl-10">
|
||||||
<p className="editorial-eyebrow mb-6">{t('history')}</p>
|
<p className="editorial-eyebrow mb-6">{t('history')}</p>
|
||||||
<WeightHistory entries={entries} goalKg={goalKg} />
|
<WeightHistory entries={entries} goalKg={goalKg} startKg={startKg} />
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -67,9 +67,11 @@ const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
|
|||||||
export function WeightHistory({
|
export function WeightHistory({
|
||||||
entries,
|
entries,
|
||||||
goalKg,
|
goalKg,
|
||||||
|
startKg,
|
||||||
}: {
|
}: {
|
||||||
entries: WeightEntryRow[];
|
entries: WeightEntryRow[];
|
||||||
goalKg?: number | null;
|
goalKg?: number | null;
|
||||||
|
startKg?: number | null;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations('Tracking');
|
const t = useTranslations('Tracking');
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
@@ -98,6 +100,16 @@ export function WeightHistory({
|
|||||||
})
|
})
|
||||||
: null;
|
: 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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
@@ -108,7 +120,7 @@ export function WeightHistory({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-ink/10 grid grid-cols-2 gap-6 border-y py-5 sm:grid-cols-3">
|
<div className="border-ink/10 grid grid-cols-2 gap-6 border-y py-5 sm:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="editorial-eyebrow">{t('current')}</p>
|
<p className="editorial-eyebrow">{t('current')}</p>
|
||||||
<p className="text-ink mt-2 font-mono text-3xl font-medium leading-none tracking-tight">
|
<p className="text-ink mt-2 font-mono text-3xl font-medium leading-none tracking-tight">
|
||||||
@@ -146,6 +158,15 @@ export function WeightHistory({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{journeyPercent != null ? (
|
||||||
|
<div>
|
||||||
|
<p className="editorial-eyebrow">{t('journey')}</p>
|
||||||
|
<p className="text-ink mt-2 font-mono text-3xl font-medium leading-none tracking-tight">
|
||||||
|
{journeyPercent}
|
||||||
|
<span className="font-display text-ink-soft ml-1 text-xs italic">%</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{projectedDate && goalKg != null ? (
|
{projectedDate && goalKg != null ? (
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export const notificationSettingsSchema = z
|
|||||||
.object({
|
.object({
|
||||||
weeklyWeighIn: z.boolean().default(false),
|
weeklyWeighIn: z.boolean().default(false),
|
||||||
fastingMilestones: 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();
|
.strict();
|
||||||
|
|
||||||
@@ -13,6 +15,7 @@ export type NotificationSettings = z.infer<typeof notificationSettingsSchema>;
|
|||||||
export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = {
|
export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = {
|
||||||
weeklyWeighIn: false,
|
weeklyWeighIn: false,
|
||||||
fastingMilestones: false,
|
fastingMilestones: false,
|
||||||
|
mealReminders: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Body inviato dal client al backend per registrare una subscription web.
|
// Body inviato dal client al backend per registrare una subscription web.
|
||||||
|
|||||||
Reference in new issue
Block a user