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
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in new issue
Block a user