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
+313
-15
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 };
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
<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,7 +126,15 @@ export function ShoppingChecklist({ list }: { list: ShoppingList }) {
|
||||
</header>
|
||||
|
||||
<ul className="divide-ink/10 divide-y">
|
||||
{group.items.map((line) => {
|
||||
{[...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 (
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user