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

@@ -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 };
}
+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 };
},
);
+1
View File
@@ -13,6 +13,7 @@ async function start(): Promise<void> {
app.addHook('onClose', async () => {
cronHandles?.weekly.stop();
cronHandles?.fasting.stop();
cronHandles?.meals.forEach((m) => m.stop());
});
try {
+20 -1
View File
@@ -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",
@@ -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<PlanSummary | null>(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 }) {
</p>
</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">
<NextCard
eyebrow={t('nextPlanEyebrow')}
@@ -20,7 +20,7 @@ export interface NotificationDevice {
export interface NotificationConfig {
pushEnabled: boolean;
settings: { weeklyWeighIn: boolean; fastingMilestones: boolean };
settings: { weeklyWeighIn: boolean; fastingMilestones: boolean; mealReminders: boolean };
devices: NotificationDevice[];
}
@@ -70,7 +70,7 @@ export async function unregisterDevice(deviceId: string): Promise<ActionResult>
}
export async function updateNotificationSettings(
patch: Partial<{ weeklyWeighIn: boolean; fastingMilestones: boolean }>,
patch: Partial<{ weeklyWeighIn: boolean; fastingMilestones: boolean; mealReminders: boolean }>,
): Promise<ActionResult> {
const res = await fetch(`${API_URL}/me/notifications/settings`, {
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 () => {
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
/>
<ToggleRow
label={t('mealRemindersLabel')}
description={t('mealRemindersHint')}
checked={cfg.settings.mealReminders ?? false}
onChange={(v) => handleToggle('mealReminders', v)}
disabled={pending || !pushReady}
/>
</div>
@@ -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 (
<div className="space-y-12">
<p className="font-display text-ink-soft text-base italic leading-snug">
{t('checked', { n: checkedCount, total: totalCount })}
</p>
<div className="flex flex-wrap items-baseline justify-between gap-4">
<p className="font-display text-ink-soft text-base italic leading-snug">
{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) => (
<section
@@ -99,46 +126,54 @@ export function ShoppingChecklist({ list }: { list: ShoppingList }) {
</header>
<ul className="divide-ink/10 divide-y">
{group.items.map((line) => {
const key = lineKey(line);
const isChecked = !!checked[key];
return (
<li key={key}>
<button
type="button"
onClick={() => toggle(line)}
aria-pressed={isChecked}
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
aria-hidden
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">
{[...group.items]
.sort((a, b) => {
// PRD §6.5 — gli articoli spuntati vanno in fondo.
const ac = checked[lineKey(a)] ? 1 : 0;
const bc = checked[lineKey(b)] ? 1 : 0;
if (ac !== bc) return ac - bc;
return a.name.localeCompare(b.name, 'it');
})
.map((line) => {
const key = lineKey(line);
const isChecked = !!checked[key];
return (
<li key={key}>
<button
type="button"
onClick={() => toggle(line)}
aria-pressed={isChecked}
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
className={`font-display text-ink text-lg leading-tight transition-colors ${
isChecked ? 'text-ink-dim line-through' : ''
aria-hidden
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 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' : ''
}`}
>
{formatQuantity(line.quantity)} {line.unit}
</span>
</button>
</li>
);
})}
</button>
</li>
);
})}
</ul>
</section>
))}
+3 -1
View File
@@ -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({
</section>
<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>
<WeightHistory entries={entries} goalKg={goalKg} />
<WeightHistory entries={entries} goalKg={goalKg} startKg={startKg} />
</aside>
</div>
</main>
@@ -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 (
<div className="space-y-8">
<div>
@@ -108,7 +120,7 @@ export function WeightHistory({
</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>
<p className="editorial-eyebrow">{t('current')}</p>
<p className="text-ink mt-2 font-mono text-3xl font-medium leading-none tracking-tight">
@@ -146,6 +158,15 @@ export function WeightHistory({
</p>
</div>
) : 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>
{projectedDate && goalKg != null ? (
@@ -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<typeof notificationSettingsSchema>;
export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = {
weeklyWeighIn: false,
fastingMilestones: false,
mealReminders: false,
};
// Body inviato dal client al backend per registrare una subscription web.