diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 8f2cd18..3c84375 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -6,6 +6,7 @@ import Fastify, { type FastifyInstance } from 'fastify'; import { env } from './config/env.js'; import { Sentry } from './lib/sentry.js'; +import { achievementsRoutes } from './modules/achievements/achievements.routes.js'; import { dbRoutes } from './modules/db/db.routes.js'; import { healthRoutes } from './modules/health/health.routes.js'; import { gdprRoutes } from './modules/me/gdpr.routes.js'; @@ -66,6 +67,7 @@ export async function buildApp(): Promise { await app.register(planExportRoutes); await app.register(shoppingRoutes); await app.register(notificationsRoutes); + await app.register(achievementsRoutes); if (env.SENTRY_DSN) { app.setErrorHandler((err, request, reply) => { diff --git a/apps/api/src/modules/achievements/achievements.routes.ts b/apps/api/src/modules/achievements/achievements.routes.ts new file mode 100644 index 0000000..0db301b --- /dev/null +++ b/apps/api/src/modules/achievements/achievements.routes.ts @@ -0,0 +1,24 @@ +// PRD §6 — route achievement: lista degli sbloccati per l'utente, con i +// "locked" inferiti lato client dal catalogo statico. + +import { ACHIEVEMENT_KEYS, type AchievementKey } from '@ketopath/shared'; +import type { FastifyPluginAsync } from 'fastify'; + +import { requireAuth } from '../../plugins/auth.js'; + +export const achievementsRoutes: FastifyPluginAsync = async (fastify) => { + fastify.get('/me/achievements', { preHandler: requireAuth() }, async (request) => { + const userId = request.user!.id; + const rows = await fastify.prisma.achievement.findMany({ + where: { userId }, + orderBy: { unlockedAt: 'desc' }, + select: { key: true, unlockedAt: true }, + }); + // Filtra eventuali key legacy non più nel catalogo: niente errori UI. + const validKeys = new Set(ACHIEVEMENT_KEYS); + const unlocked = rows + .filter((r) => validKeys.has(r.key)) + .map((r) => ({ key: r.key as AchievementKey, unlockedAt: r.unlockedAt.toISOString() })); + return { unlocked }; + }); +}; diff --git a/apps/api/src/modules/achievements/service.ts b/apps/api/src/modules/achievements/service.ts new file mode 100644 index 0000000..bad0dfa --- /dev/null +++ b/apps/api/src/modules/achievements/service.ts @@ -0,0 +1,97 @@ +// PRD §6 — service achievement: orchestra l'eval e la persistenza. +// Idempotente: rigirare la funzione senza nuovi eventi non duplica nulla +// e non emette notifiche. Pensato per essere chiamato dopo i 4 trigger: +// weight save, plan create, fast complete, slot consume. + +import type { ExtendedPrismaClient } from '@ketopath/db'; +import { evaluateAchievements, type AchievementKey } from '@ketopath/shared'; + +import { pushIsEnabled, sendToDevice } from '../notifications/sender.js'; + +interface UnlockOutput { + newlyUnlocked: AchievementKey[]; +} + +export async function evaluateAndPersist( + prisma: ExtendedPrismaClient, + userId: string, +): Promise { + // Misuriamo le metriche correnti in parallelo. Niente filtri sulla data: + // è uno snapshot. La unique constraint su (userId, key) ci protegge dai + // doppioni quando l'utente innesca trigger ripetuti. + const [weightEntryCount, planCount, completedFastCount, consumedMealCount] = await Promise.all([ + prisma.weightEntry.count({ where: { userId } }), + prisma.mealPlan.count({ where: { userId } }), + prisma.fastEvent.count({ where: { userId, status: 'COMPLETED' } }), + prisma.mealSlot.count({ where: { plan: { userId }, consumed: true } }), + ]); + + const should = evaluateAchievements({ + weightEntryCount, + planCount, + completedFastCount, + consumedMealCount, + }); + if (should.length === 0) return { newlyUnlocked: [] }; + + const existing = await prisma.achievement.findMany({ + where: { userId, key: { in: should } }, + select: { key: true }, + }); + const have = new Set(existing.map((a) => a.key)); + const newlyUnlocked = should.filter((k) => !have.has(k)); + if (newlyUnlocked.length === 0) return { newlyUnlocked: [] }; + + // createMany con skipDuplicates: race-safe contro chiamate concorrenti. + await prisma.achievement.createMany({ + data: newlyUnlocked.map((key) => ({ userId, key })), + skipDuplicates: true, + }); + + return { newlyUnlocked }; +} + +const ACHIEVEMENT_PUSH_COPY: Record = { + first_weigh_in: { + title: 'Achievement sbloccato: Prima pesata', + body: 'Hai messo il primo punto sulla mappa. Ben fatto.', + }, + first_plan: { + title: 'Achievement sbloccato: Primo piano', + body: 'Hai il tuo piano della settimana. Si parte.', + }, + first_fast_complete: { + title: 'Achievement sbloccato: Primo digiuno', + body: 'Sessione completata. Costanza è la chiave.', + }, + ten_meals_consumed: { + title: 'Achievement sbloccato: 10 pasti', + body: 'Dieci pasti spuntati. La routine sta diventando un ritmo.', + }, +}; + +// Best-effort: non blocchiamo la response principale se push fallisce. +export async function notifyUnlocked( + prisma: ExtendedPrismaClient, + userId: string, + keys: AchievementKey[], +): Promise { + if (keys.length === 0 || !pushIsEnabled()) return; + const tokens = await prisma.deviceToken.findMany({ where: { userId } }); + if (tokens.length === 0) return; + for (const key of keys) { + const copy = ACHIEVEMENT_PUSH_COPY[key]; + for (const token of tokens) { + const r = await sendToDevice(token, { + title: copy.title, + body: copy.body, + url: '/profile', + }); + if (r.expired) { + await prisma.deviceToken.delete({ where: { id: token.id } }).catch(() => { + /* race */ + }); + } + } + } +} diff --git a/apps/api/src/modules/plan/plan.routes.ts b/apps/api/src/modules/plan/plan.routes.ts index 1c85493..b753139 100644 --- a/apps/api/src/modules/plan/plan.routes.ts +++ b/apps/api/src/modules/plan/plan.routes.ts @@ -24,6 +24,7 @@ import { import type { FastifyPluginAsync } from 'fastify'; import { requireAuth } from '../../plugins/auth.js'; +import { evaluateAndPersist, notifyUnlocked } from '../achievements/service.js'; const MEALS = ['COLAZIONE', 'PRANZO', 'SPUNTINO', 'CENA'] as const; @@ -93,7 +94,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { prepMinutes: true, ingredients: { select: { - ingredient: { select: { exclusionGroups: true, phase2Week: true } }, + ingredient: { select: { id: true, exclusionGroups: true, phase2Week: true } }, }, }, }, @@ -117,8 +118,10 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { const candidates: RecipeCandidate[] = allowedRecipes.map((r) => { const tags = new Set(); + const ingredientIds: string[] = []; for (const ri of r.ingredients) { for (const g of ri.ingredient.exclusionGroups) tags.add(g); + ingredientIds.push(ri.ingredient.id); } return { id: r.id, @@ -129,6 +132,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { fatG: r.fatG, netCarbG: r.netCarbG, exclusionTags: Array.from(tags), + ingredientIds, phases: r.phases.filter((p): p is 1 | 2 | 3 => p === 1 || p === 2 || p === 3), prepMinutes: r.prepMinutes, }; @@ -186,6 +190,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { phase: phaseInt, }); const exclusions = profile.user.preferences?.exclusions ?? []; + const bannedIngredientIds = profile.user.preferences?.bannedIngredientIds ?? []; const fastingProtocol = (profile.user.preferences?.fastingProtocol as FastingProtocolKey | null | undefined) ?? null; const cookingTime = @@ -239,6 +244,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { meal, phase: phaseInt, excludedTags: exclusions, + bannedIngredientIds, recentlyConsumedIds, dailyTarget, mealShare: effectiveShare, @@ -259,6 +265,13 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { } } + const ach = await evaluateAndPersist(fastify.prisma, userId); + if (ach.newlyUnlocked.length > 0) { + void notifyUnlocked(fastify.prisma, userId, ach.newlyUnlocked).catch(() => { + /* notifica best-effort */ + }); + } + return reply.code(201).send({ plan: { id: plan.id, @@ -266,6 +279,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { dailyTarget: baseDailyTarget, fastingProtocol, }, + newlyUnlocked: ach.newlyUnlocked, }); }); @@ -366,7 +380,18 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { } } - return { slot: updated, autoStartedFast }; + let newlyUnlocked: string[] = []; + if (next) { + const ach = await evaluateAndPersist(fastify.prisma, userId); + newlyUnlocked = ach.newlyUnlocked; + if (newlyUnlocked.length > 0) { + void notifyUnlocked(fastify.prisma, userId, ach.newlyUnlocked).catch(() => { + /* notifica best-effort */ + }); + } + } + + return { slot: updated, autoStartedFast, newlyUnlocked }; }, ); @@ -450,13 +475,19 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { netCarbG: true, phases: true, prepMinutes: true, - ingredients: { select: { ingredient: { select: { exclusionGroups: true } } } }, + ingredients: { + select: { ingredient: { select: { id: true, exclusionGroups: true } } }, + }, }, }); const candidates: RecipeCandidate[] = recipes.map((r) => { const tags = new Set(); - for (const ri of r.ingredients) for (const g of ri.ingredient.exclusionGroups) tags.add(g); + const ingredientIds: string[] = []; + for (const ri of r.ingredients) { + for (const g of ri.ingredient.exclusionGroups) tags.add(g); + ingredientIds.push(ri.ingredient.id); + } return { id: r.id, name: r.name, @@ -466,6 +497,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { fatG: r.fatG, netCarbG: r.netCarbG, exclusionTags: Array.from(tags), + ingredientIds, phases: r.phases.filter((p): p is 1 | 2 | 3 => p === 1 || p === 2 || p === 3), prepMinutes: r.prepMinutes, }; @@ -536,6 +568,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { ); const exclusions = profile.user.preferences?.exclusions ?? []; + const bannedIngredientIds = profile.user.preferences?.bannedIngredientIds ?? []; const cookingTime = (profile.user.preferences?.cookingTime as 'LOW' | 'MEDIUM' | 'HIGH' | null | undefined) ?? null; @@ -553,6 +586,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { meal: slot.meal, phase: phaseInt, excludedTags: exclusions, + bannedIngredientIds, recentlyConsumedIds, consumedSoFar, dailyTarget, diff --git a/apps/api/src/modules/preferences/preferences.routes.ts b/apps/api/src/modules/preferences/preferences.routes.ts index 4cec2d8..d431f3c 100644 --- a/apps/api/src/modules/preferences/preferences.routes.ts +++ b/apps/api/src/modules/preferences/preferences.routes.ts @@ -15,6 +15,7 @@ function serialize(prefs: { trainingType: string | null; sessionMinutes: number | null; mealsPerDay: number | null; + bannedIngredientIds: string[]; }) { return { exclusions: prefs.exclusions, @@ -25,6 +26,7 @@ function serialize(prefs: { trainingType: prefs.trainingType, sessionMinutes: prefs.sessionMinutes, mealsPerDay: prefs.mealsPerDay, + bannedIngredientIds: prefs.bannedIngredientIds, }; } @@ -37,6 +39,7 @@ const EMPTY_PREFS = { trainingType: null, sessionMinutes: null, mealsPerDay: null, + bannedIngredientIds: [], }; export const preferencesRoutes: FastifyPluginAsync = async (fastify) => { @@ -64,6 +67,7 @@ export const preferencesRoutes: FastifyPluginAsync = async (fastify) => { trainingType: data.trainingType ?? null, sessionMinutes: data.sessionMinutes ?? null, mealsPerDay: data.mealsPerDay ?? null, + bannedIngredientIds: data.bannedIngredientIds ?? [], }; const update: Record = {}; if (data.exclusions !== undefined) update.exclusions = data.exclusions; @@ -74,6 +78,8 @@ export const preferencesRoutes: FastifyPluginAsync = async (fastify) => { if (data.trainingType !== undefined) update.trainingType = data.trainingType; if (data.sessionMinutes !== undefined) update.sessionMinutes = data.sessionMinutes; if (data.mealsPerDay !== undefined) update.mealsPerDay = data.mealsPerDay; + if (data.bannedIngredientIds !== undefined) + update.bannedIngredientIds = data.bannedIngredientIds; const prefs = await fastify.prisma.preferences.upsert({ where: { userId }, @@ -83,4 +89,33 @@ export const preferencesRoutes: FastifyPluginAsync = async (fastify) => { return { preferences: serialize(prefs) }; }); + + // PRD §6 — catalogo ingredienti per la UI di esclusioni granulari. + // Ricerca case-insensitive per nome, oppure filtro per ids (per resolvere + // i bannati attualmente salvati). Max 50 risultati. + fastify.get('/me/ingredients', { preHandler: requireAuth() }, async (request) => { + const query = request.query as { q?: string; ids?: string }; + const q = query.q?.trim() ?? ''; + const idsRaw = query.ids?.trim() ?? ''; + const where = + idsRaw.length > 0 + ? { + id: { + in: idsRaw + .split(',') + .filter((s) => s.length > 0) + .slice(0, 50), + }, + } + : q.length > 0 + ? { name: { contains: q, mode: 'insensitive' as const } } + : {}; + const ingredients = await fastify.prisma.ingredient.findMany({ + where, + select: { id: true, name: true, category: true, exclusionGroups: true }, + orderBy: { name: 'asc' }, + take: 50, + }); + return { ingredients }; + }); }; diff --git a/apps/api/src/modules/profile/profile.routes.ts b/apps/api/src/modules/profile/profile.routes.ts index f16fe39..4478e72 100644 --- a/apps/api/src/modules/profile/profile.routes.ts +++ b/apps/api/src/modules/profile/profile.routes.ts @@ -1,11 +1,16 @@ import { ACTIVITY_MULTIPLIERS, + bmrAdjustForDietHistory, + bmrAdjustmentForConditions, calculateBmr, calculateBmrKatchMcArdle, calculateTdee, + computeDailyKcalTarget, estimateBodyFatPercentageUSNavy, + macrosForPhase, profileInputSchema, type ActivityLevel, + type DietHistory, type Gender, } from '@ketopath/shared'; import type { FastifyPluginAsync } from 'fastify'; @@ -36,7 +41,7 @@ function serialize( if (!profile) return null; const weightCurrentKg = Number(profile.weightCurrentKg); const bodyFatPct = profile.bodyFatPct ? Number(profile.bodyFatPct) : null; - const bmr = bodyFatPct + let bmr = bodyFatPct ? calculateBmrKatchMcArdle(weightCurrentKg, bodyFatPct) : calculateBmr({ weightKg: weightCurrentKg, @@ -44,7 +49,23 @@ function serialize( ageYears: profile.age, gender: profile.gender, }); + const conditions = parseConditions(profile.medicalConditions); + bmr *= bmrAdjustmentForConditions(conditions); + bmr *= bmrAdjustForDietHistory(profile.dietHistory as DietHistory | null); const tdee = calculateTdee(bmr, profile.activityLevel); + const phaseInt = + profile.currentPhase === 'INTENSIVE' ? 1 : profile.currentPhase === 'TRANSITION' ? 2 : 3; + const { kcalTarget } = computeDailyKcalTarget({ + tdee, + weightCurrentKg, + targetWeeklyLossKg: profile.targetWeeklyLossKg, + phase: phaseInt, + }); + const macroTarget = macrosForPhase({ + kcalTarget, + weightKg: weightCurrentKg, + phase: phaseInt, + }); return { age: profile.age, gender: profile.gender, @@ -55,7 +76,7 @@ function serialize( activityLevel: profile.activityLevel, targetDate: profile.targetDate, targetWeeklyLossKg: profile.targetWeeklyLossKg, - medicalConditions: parseConditions(profile.medicalConditions), + medicalConditions: conditions, bodyFatPct, alertWeightKg: profile.alertWeightKg ? Number(profile.alertWeightKg) : null, dietHistory: profile.dietHistory, @@ -65,6 +86,10 @@ function serialize( tdee: Math.round(tdee), activityMultiplier: ACTIVITY_MULTIPLIERS[profile.activityLevel], bmrFormula: bodyFatPct ? 'katch_mcardle' : 'mifflin_st_jeor', + kcalTarget: Math.round(kcalTarget), + proteinTargetG: Math.round(macroTarget.proteinG), + fatTargetG: Math.round(macroTarget.fatG), + netCarbTargetG: Math.round(macroTarget.netCarbG), }, }; } diff --git a/apps/api/src/modules/tracking/fast.routes.ts b/apps/api/src/modules/tracking/fast.routes.ts index 750bee0..8258879 100644 --- a/apps/api/src/modules/tracking/fast.routes.ts +++ b/apps/api/src/modules/tracking/fast.routes.ts @@ -6,6 +6,7 @@ import { import type { FastifyPluginAsync } from 'fastify'; import { requireAuth } from '../../plugins/auth.js'; +import { evaluateAndPersist, notifyUnlocked } from '../achievements/service.js'; export const fastRoutes: FastifyPluginAsync = async (fastify) => { fastify.get('/me/fast-events', { preHandler: requireAuth() }, async (request) => { @@ -71,7 +72,17 @@ export const fastRoutes: FastifyPluginAsync = async (fastify) => { where: { id }, data: updateData, }); - return { event: { id: event.id, status: event.status } }; + let newlyUnlocked: string[] = []; + if (event.status === 'COMPLETED') { + const ach = await evaluateAndPersist(fastify.prisma, request.user!.id); + newlyUnlocked = ach.newlyUnlocked; + if (newlyUnlocked.length > 0) { + void notifyUnlocked(fastify.prisma, request.user!.id, ach.newlyUnlocked).catch(() => { + /* notifica best-effort */ + }); + } + } + return { event: { id: event.id, status: event.status }, newlyUnlocked }; }); // PRD §5.3 — modalità "giorno libero". Mette in pausa i reminder fino a diff --git a/apps/api/src/modules/tracking/weight.routes.ts b/apps/api/src/modules/tracking/weight.routes.ts index 2024a9f..4b20c97 100644 --- a/apps/api/src/modules/tracking/weight.routes.ts +++ b/apps/api/src/modules/tracking/weight.routes.ts @@ -2,6 +2,7 @@ import { weightEntryInputSchema } from '@ketopath/shared'; import type { FastifyPluginAsync } from 'fastify'; import { requireAuth } from '../../plugins/auth.js'; +import { evaluateAndPersist, notifyUnlocked } from '../achievements/service.js'; export const weightRoutes: FastifyPluginAsync = async (fastify) => { fastify.get('/me/weight-entries', { preHandler: requireAuth() }, async (request) => { @@ -57,8 +58,16 @@ export const weightRoutes: FastifyPluginAsync = async (fastify) => { }, }); - return reply - .code(201) - .send({ entry: { id: entry.id, date: entry.date.toISOString().slice(0, 10) } }); + const ach = await evaluateAndPersist(fastify.prisma, userId); + if (ach.newlyUnlocked.length > 0) { + void notifyUnlocked(fastify.prisma, userId, ach.newlyUnlocked).catch(() => { + /* notifica best-effort */ + }); + } + + return reply.code(201).send({ + entry: { id: entry.id, date: entry.date.toISOString().slice(0, 10) }, + newlyUnlocked: ach.newlyUnlocked, + }); }); }; diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index 45551b4..a3188a5 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -334,6 +334,13 @@ "subtitle": "Le esclusioni filtrano gli ingredienti, le cucine influenzano il matchmaking, il tempo decide quante ricette elaborate proporre.", "exclusionsTitle": "Esclusioni alimentari", "exclusionsHint": "Tutte le ricette che contengono almeno uno di questi gruppi verranno escluse dal piano.", + "bannedIngredientsTitle": "Ingredienti specifici da evitare", + "bannedIngredientsHint": "Cerca un ingrediente per nome (es. salmone, tofu, peperone). Le ricette che lo contengono verranno escluse dal piano. Massimo 50.", + "bannedIngredientsSearchPlaceholder": "Cerca un ingrediente…", + "bannedIngredientsSearching": "Sto cercando…", + "bannedIngredientsEmpty": "Nessun ingrediente bandito per ora.", + "bannedIngredientsRemove": "Rimuovi", + "bannedIngredientsCap": "Hai raggiunto il massimo di 50 ingredienti.", "cuisinesTitle": "Cucine preferite", "cuisinesHint": "Indicaci le tradizioni che preferisci. È un suggerimento, non un vincolo.", "cookingTimeTitle": "Tempo in cucina", @@ -538,6 +545,9 @@ "avgProtein": "Proteine medie", "avgFat": "Grassi medi", "avgNetCarb": "Carb. netti medi", + "todayPanelTitle": "Oggi · macros del giorno", + "todayPanelTargetHint": "Consumati / target — pianificati in chiaro", + "todayPanelFasting": "Niente pasti pianificati per oggi: è un giorno di digiuno completo.", "fastingDay": "Giorno di digiuno", "fastingDayHint": "Acqua, tè e tisane. Niente cibo solido.", "lightDay": "Giorno leggero", @@ -566,6 +576,35 @@ "sunday": "Domenica" } }, + "Achievements": { + "eyebrow": "Traguardi", + "title": "Le tappe del tuo percorso", + "subtitle": "Hai sbloccato {got} traguardi su {total}. Continua così.", + "locked": "Da sbloccare", + "category": { + "tracking": "Tracking", + "plan": "Piano", + "fasting": "Digiuno" + }, + "key": { + "first_weigh_in": { + "title": "Prima pesata", + "hint": "Hai registrato il tuo peso per la prima volta." + }, + "first_plan": { + "title": "Primo piano", + "hint": "Hai generato il tuo primo piano settimanale." + }, + "first_fast_complete": { + "title": "Primo digiuno completato", + "hint": "Hai portato a termine la tua prima sessione di digiuno." + }, + "ten_meals_consumed": { + "title": "Dieci pasti spuntati", + "hint": "Hai marcato come consumati 10 pasti dal tuo piano." + } + } + }, "Profile": { "title": "Il tuo profilo", "subtitle": "I dati che ci servono per calcolare BMR, TDEE e i target di macros della settimana.", diff --git a/apps/web/src/app/[locale]/onboarding/onboarding-flow.tsx b/apps/web/src/app/[locale]/onboarding/onboarding-flow.tsx index 4c8ddd8..f17c26e 100644 --- a/apps/web/src/app/[locale]/onboarding/onboarding-flow.tsx +++ b/apps/web/src/app/[locale]/onboarding/onboarding-flow.tsx @@ -740,5 +740,6 @@ function toFormValues(view: PreferencesView): PreferencesValues { trainingType: view.trainingType as PreferencesValues['trainingType'], sessionMinutes: view.sessionMinutes, mealsPerDay: view.mealsPerDay, + bannedIngredientIds: view.bannedIngredientIds ?? [], }; } diff --git a/apps/web/src/app/[locale]/plan/actions.ts b/apps/web/src/app/[locale]/plan/actions.ts index 3d49ed6..5ed3a35 100644 --- a/apps/web/src/app/[locale]/plan/actions.ts +++ b/apps/web/src/app/[locale]/plan/actions.ts @@ -44,6 +44,13 @@ export interface CurrentPlan { phase2Week: number | null; } +export interface DailyTarget { + kcal: number; + proteinG: number; + fatG: number; + netCarbG: number; +} + function cookieHeader(): string { return headers().get('cookie') ?? ''; } @@ -59,6 +66,32 @@ export async function fetchCurrentPlan(): Promise { return data.plan; } +export async function fetchDailyTarget(): Promise { + const res = await fetch(`${API_URL}/me/profile`, { + headers: { cookie: cookieHeader() }, + cache: 'no-store', + }); + if (!res.ok) return null; + const data = (await res.json()) as { + profile?: { + derived?: { + kcalTarget?: number; + proteinTargetG?: number; + fatTargetG?: number; + netCarbTargetG?: number; + }; + }; + }; + const d = data.profile?.derived; + if (!d || d.kcalTarget == null || d.proteinTargetG == null) return null; + return { + kcal: d.kcalTarget, + proteinG: d.proteinTargetG, + fatG: d.fatTargetG ?? 0, + netCarbG: d.netCarbTargetG ?? 0, + }; +} + // Server action invocata via `
`. La firma è // `(FormData) => Promise` per allinearsi al type richiesto da React 18. export async function regeneratePlan(_formData?: FormData): Promise { diff --git a/apps/web/src/app/[locale]/plan/page.tsx b/apps/web/src/app/[locale]/plan/page.tsx index ecc29d5..2702434 100644 --- a/apps/web/src/app/[locale]/plan/page.tsx +++ b/apps/web/src/app/[locale]/plan/page.tsx @@ -8,7 +8,7 @@ import { Masthead } from '@/components/masthead'; import { Button } from '@/components/ui/button'; import { getServerSession } from '@/lib/auth'; -import { fetchCurrentPlan, regeneratePlan } from './actions'; +import { fetchCurrentPlan, fetchDailyTarget, regeneratePlan } from './actions'; import { PlanWeek } from './plan-week'; export default async function PlanPage({ params: { locale } }: { params: { locale: string } }) { @@ -23,9 +23,9 @@ export default async function PlanPage({ params: { locale } }: { params: { local if (!user?.disclaimerAcceptedAt) redirect('/welcome'); if (!user.profile) redirect('/onboarding'); - const plan = await fetchCurrentPlan(); + const [plan, dailyTarget] = await Promise.all([fetchCurrentPlan(), fetchDailyTarget()]); - return ; + return ; } const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', { @@ -45,7 +45,13 @@ function formatWeek(weekStart: string): string { return `${startStr} – ${endStr}`; } -function PlanPageContent({ plan }: { plan: Awaited> }) { +function PlanPageContent({ + plan, + dailyTarget, +}: { + plan: Awaited>; + dailyTarget: Awaited>; +}) { const t = useTranslations('Plan'); return ( @@ -114,7 +120,7 @@ function PlanPageContent({ plan }: { plan: Awaited - + ) : null} diff --git a/apps/web/src/app/[locale]/plan/plan-week.tsx b/apps/web/src/app/[locale]/plan/plan-week.tsx index 55faa92..daea98d 100644 --- a/apps/web/src/app/[locale]/plan/plan-week.tsx +++ b/apps/web/src/app/[locale]/plan/plan-week.tsx @@ -13,6 +13,7 @@ import { toggleConsumed, toggleFreeMeal, type CurrentPlan, + type DailyTarget, type PlanSlot, } from './actions'; @@ -61,7 +62,19 @@ function aggregateMacros(slots: PlanSlot[]): { const FREE_MEAL_DEFAULT_KCAL = 750; -export function PlanWeek({ plan }: { plan: CurrentPlan }) { +// PRD §6 — il pannello macros laterale ancora oggi (lun=0…dom=6). +function todayDayOfWeek(): number { + const d = new Date().getDay(); // 0 = Sunday + return d === 0 ? 6 : d - 1; +} + +export function PlanWeek({ + plan, + dailyTarget, +}: { + plan: CurrentPlan; + dailyTarget: DailyTarget | null; +}) { const t = useTranslations('Plan'); const byDay = new Map(); @@ -70,6 +83,11 @@ export function PlanWeek({ plan }: { plan: CurrentPlan }) { byDay.get(slot.dayOfWeek)!.push(slot); } + const today = todayDayOfWeek(); + const todaySlots = byDay.get(today) ?? []; + const todayConsumed = aggregateMacros(todaySlots.filter((s) => s.consumed)); + const todayPlanned = aggregateMacros(todaySlots); + const isPhase3 = plan.currentPhase === 'MAINTENANCE'; const weekTotals = aggregateMacros(plan.slots); const activeDays = Array.from(byDay.values()).filter((s) => s.length > 0).length; @@ -93,76 +111,87 @@ export function PlanWeek({ plan }: { plan: CurrentPlan }) { ); return ( -
-
- - - - -
- {t('adherence')} - - {adherence.pastSlots === 0 ? '—' : `${Math.round(adherence.rate * 100)}%`} - - {adherence.pastSlots > 0 ? ( - - {adherence.consumedSlots}/{adherence.pastSlots} +
+
+
+ + + + +
+ {t('adherence')} + + {adherence.pastSlots === 0 ? '—' : `${Math.round(adherence.rate * 100)}%`} - ) : null} -
-
- - {DAY_KEYS.map((dayKey, idx) => { - const slots = (byDay.get(idx) ?? []).sort( - (a, b) => MEAL_ORDER.indexOf(a.meal) - MEAL_ORDER.indexOf(b.meal), - ); - const dayMacros = aggregateMacros(slots); - // Nessuno slot → giorno di digiuno completo (ESE_24). - const isFastingDay = slots.length === 0; - // Solo CENA con dailyKcal ridotto → giorno "leggero" del 5:2. - const isLightDay = !isFastingDay && slots.length === 1 && slots[0].meal === 'CENA'; - - return ( -
-
- - {DAY_NUMERAL[idx]} + {adherence.pastSlots > 0 ? ( + + {adherence.consumedSlots}/{adherence.pastSlots} -

- {t(`days.${dayKey}`)} - {isFastingDay ? ( - - · {t('fastingDay')} - - ) : isLightDay ? ( - - · {t('lightDay')} - - ) : null} -

- - {isFastingDay - ? '— kcal' - : `${Math.round(dayMacros.kcal)} kcal · P ${Math.round(dayMacros.proteinG)} · G ${Math.round(dayMacros.fatG)} · C ${Math.round(dayMacros.netCarbG)}`} - -
- {isFastingDay ? ( -

- {t('fastingDayHint')} -

- ) : ( -
- {slots.map((slot, slotIdx) => ( - - ))} -
- )} -
- ); - })} + ) : null} +
+
+ + {DAY_KEYS.map((dayKey, idx) => { + const slots = (byDay.get(idx) ?? []).sort( + (a, b) => MEAL_ORDER.indexOf(a.meal) - MEAL_ORDER.indexOf(b.meal), + ); + const dayMacros = aggregateMacros(slots); + // Nessuno slot → giorno di digiuno completo (ESE_24). + const isFastingDay = slots.length === 0; + // Solo CENA con dailyKcal ridotto → giorno "leggero" del 5:2. + const isLightDay = !isFastingDay && slots.length === 1 && slots[0].meal === 'CENA'; + + return ( +
+
+ + {DAY_NUMERAL[idx]} + +

+ {t(`days.${dayKey}`)} + {isFastingDay ? ( + + · {t('fastingDay')} + + ) : isLightDay ? ( + + · {t('lightDay')} + + ) : null} +

+ + {isFastingDay + ? '— kcal' + : `${Math.round(dayMacros.kcal)} kcal · P ${Math.round(dayMacros.proteinG)} · G ${Math.round(dayMacros.fatG)} · C ${Math.round(dayMacros.netCarbG)}`} + +
+ {isFastingDay ? ( +

+ {t('fastingDayHint')} +

+ ) : ( +
+ {slots.map((slot, slotIdx) => ( + + ))} +
+ )} +
+ ); + })} +
+ + ); } @@ -327,6 +356,128 @@ function SlotCard({ ); } +interface MacroBucket { + kcal: number; + proteinG: number; + fatG: number; + netCarbG: number; +} + +function TodayMacrosPanel({ + consumed, + planned, + target, + hasSlots, +}: { + consumed: MacroBucket; + planned: MacroBucket; + target: DailyTarget | null; + hasSlots: boolean; +}) { + const t = useTranslations('Plan'); + + if (!hasSlots) { + return ( +
+

{t('todayPanelTitle')}

+

+ {t('todayPanelFasting')} +

+
+ ); + } + + return ( +
+
+

{t('todayPanelTitle')}

+ {target ? ( +

+ {t('todayPanelTargetHint')} +

+ ) : null} +
+ + + + + +
+ ); +} + +function MacroBar({ + label, + consumed, + planned, + target, + unit, +}: { + label: string; + consumed: number; + planned: number; + target: number | undefined; + unit: string; +}) { + const ref = target && target > 0 ? target : Math.max(planned, 1); + const consumedPct = Math.max(0, Math.min(100, (consumed / ref) * 100)); + const plannedPct = Math.max(0, Math.min(100, (planned / ref) * 100)); + const overTarget = target != null && consumed > target; + + return ( +
+
+ {label} + + {Math.round(consumed)} + {target != null ? ( + / {Math.round(target)} + ) : ( + / {Math.round(planned)} + )} + {unit} + +
+
+
+
+
+
+ ); +} + function MacroSummary({ label, value, diff --git a/apps/web/src/app/[locale]/profile/achievements-actions.ts b/apps/web/src/app/[locale]/profile/achievements-actions.ts new file mode 100644 index 0000000..849061a --- /dev/null +++ b/apps/web/src/app/[locale]/profile/achievements-actions.ts @@ -0,0 +1,21 @@ +'use server'; + +import { type AchievementKey } from '@ketopath/shared'; +import { headers } from 'next/headers'; + +const API_URL = process.env.API_URL ?? 'http://localhost:4000'; + +export interface UnlockedAchievement { + key: AchievementKey; + unlockedAt: string; +} + +export async function fetchAchievements(): Promise { + const res = await fetch(`${API_URL}/me/achievements`, { + headers: { cookie: headers().get('cookie') ?? '' }, + cache: 'no-store', + }); + if (!res.ok) return []; + const body = (await res.json()) as { unlocked: UnlockedAchievement[] }; + return body.unlocked; +} diff --git a/apps/web/src/app/[locale]/profile/achievements-panel.tsx b/apps/web/src/app/[locale]/profile/achievements-panel.tsx new file mode 100644 index 0000000..0795a9e --- /dev/null +++ b/apps/web/src/app/[locale]/profile/achievements-panel.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { ACHIEVEMENTS } from '@ketopath/shared'; +import { useTranslations } from 'next-intl'; + +import type { UnlockedAchievement } from './achievements-actions'; + +const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', { + day: 'numeric', + month: 'long', + year: 'numeric', +}); + +export function AchievementsPanel({ unlocked }: { unlocked: UnlockedAchievement[] }) { + const t = useTranslations('Achievements'); + const map = new Map(unlocked.map((u) => [u.key, u.unlockedAt])); + const total = ACHIEVEMENTS.length; + const got = unlocked.length; + + return ( +
+
+

{t('eyebrow')}

+

+ {t('title')} +

+

+ {t('subtitle', { got, total })} +

+
+ +
    + {ACHIEVEMENTS.map((a) => { + const at = map.get(a.key); + const unlockedDate = at ? ITALIAN_DATE.format(new Date(at)) : null; + return ( +
  • +
    + + {t(`category.${a.category}`)} + + {at ? ( + + {unlockedDate} + + ) : ( + + {t('locked')} + + )} +
    +

    + {t(`key.${a.key}.title`)} +

    +

    + {t(`key.${a.key}.hint`)} +

    +
  • + ); + })} +
+
+ ); +} diff --git a/apps/web/src/app/[locale]/profile/page.tsx b/apps/web/src/app/[locale]/profile/page.tsx index 8e34d54..43830a1 100644 --- a/apps/web/src/app/[locale]/profile/page.tsx +++ b/apps/web/src/app/[locale]/profile/page.tsx @@ -6,6 +6,8 @@ import { setRequestLocale } from 'next-intl/server'; import { Masthead } from '@/components/masthead'; import { getServerSession } from '@/lib/auth'; +import { fetchAchievements } from './achievements-actions'; +import { AchievementsPanel } from './achievements-panel'; import { fetchProfile } from './actions'; import { DataPanel } from './data-panel'; import { fetchNotificationConfig } from './notifications-actions'; @@ -28,9 +30,15 @@ export default async function ProfilePage({ params: { locale } }: { params: { lo const profile = (await fetchProfile()) as Parameters[0]['initial']; const notifications = await fetchNotificationConfig(); const preferences = await fetchPreferences(); + const achievements = await fetchAchievements(); return ( - + ); } @@ -38,10 +46,12 @@ function ProfilePageContent({ initial, notifications, preferences, + achievements, }: { initial: Parameters[0]['initial']; notifications: Awaited>; preferences: Awaited>; + achievements: Awaited>; }) { const t = useTranslations('Profile'); @@ -79,6 +89,12 @@ function ProfilePageContent({
+ +
+ +
+ +
diff --git a/apps/web/src/app/[locale]/profile/preferences-actions.ts b/apps/web/src/app/[locale]/profile/preferences-actions.ts index 9414490..ca803d1 100644 --- a/apps/web/src/app/[locale]/profile/preferences-actions.ts +++ b/apps/web/src/app/[locale]/profile/preferences-actions.ts @@ -19,6 +19,39 @@ export interface PreferencesView { trainingType: string | null; sessionMinutes: number | null; mealsPerDay: number | null; + bannedIngredientIds: string[]; +} + +export interface IngredientView { + id: string; + name: string; + category: string | null; + exclusionGroups: string[]; +} + +export async function searchIngredients(query: string): Promise { + const url = new URL(`${API_URL}/me/ingredients`); + if (query.trim()) url.searchParams.set('q', query.trim()); + const res = await fetch(url.toString(), { + headers: { cookie: cookieHeader() }, + cache: 'no-store', + }); + if (!res.ok) return []; + const body = (await res.json()) as { ingredients: IngredientView[] }; + return body.ingredients; +} + +export async function fetchIngredientsByIds(ids: string[]): Promise { + if (ids.length === 0) return []; + const url = new URL(`${API_URL}/me/ingredients`); + url.searchParams.set('ids', ids.join(',')); + const res = await fetch(url.toString(), { + headers: { cookie: cookieHeader() }, + cache: 'no-store', + }); + if (!res.ok) return []; + const body = (await res.json()) as { ingredients: IngredientView[] }; + return body.ingredients; } export async function fetchPreferences(): Promise { diff --git a/apps/web/src/app/[locale]/profile/preferences-section.tsx b/apps/web/src/app/[locale]/profile/preferences-section.tsx index 14c07d1..d10c052 100644 --- a/apps/web/src/app/[locale]/profile/preferences-section.tsx +++ b/apps/web/src/app/[locale]/profile/preferences-section.tsx @@ -54,5 +54,6 @@ function toFormValues(view: PreferencesView): PreferencesValues { cuisinePreferences: view.cuisinePreferences as CuisineTag[], cookingTime: view.cookingTime as CookingTimeLevel, fastingProtocol: view.fastingProtocol as PreferencesValues['fastingProtocol'], + bannedIngredientIds: view.bannedIngredientIds ?? [], }; } diff --git a/apps/web/src/components/preferences/preferences-form.tsx b/apps/web/src/components/preferences/preferences-form.tsx index bb62d32..f0d92b2 100644 --- a/apps/web/src/components/preferences/preferences-form.tsx +++ b/apps/web/src/components/preferences/preferences-form.tsx @@ -13,8 +13,13 @@ import { type TrainingType, } from '@ketopath/shared'; import { useTranslations } from 'next-intl'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { + fetchIngredientsByIds, + searchIngredients, + type IngredientView, +} from '@/app/[locale]/profile/preferences-actions'; import { Button } from '@/components/ui/button'; import { Select, @@ -33,6 +38,7 @@ export interface PreferencesValues { trainingType: TrainingType | null; sessionMinutes: number | null; mealsPerDay: number | null; + bannedIngredientIds: string[]; } export const DEFAULT_PREFERENCES_VALUES: PreferencesValues = { @@ -44,6 +50,7 @@ export const DEFAULT_PREFERENCES_VALUES: PreferencesValues = { trainingType: null, sessionMinutes: null, mealsPerDay: null, + bannedIngredientIds: [], }; const DAY_LABELS = ['Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab', 'Dom']; @@ -102,6 +109,7 @@ export function PreferencesForm({ trainingType: values.trainingType, sessionMinutes: values.sessionMinutes, mealsPerDay: values.mealsPerDay, + bannedIngredientIds: values.bannedIngredientIds, }); setPending(false); if (!result.ok) { @@ -132,6 +140,11 @@ export function PreferencesForm({
+ setValues((prev) => ({ ...prev, bannedIngredientIds: next }))} + /> +
{t('cuisinesTitle')} @@ -380,3 +393,142 @@ function Chip({ ); } + +function BannedIngredientsField({ + value, + onChange, +}: { + value: string[]; + onChange: (next: string[]) => void; +}) { + const t = useTranslations('Preferences'); + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [resolved, setResolved] = useState>(new Map()); + const [searching, setSearching] = useState(false); + + // Risolvi i nomi degli ingredient già bannati al mount (per i chip). + useEffect(() => { + const missing = value.filter((id) => !resolved.has(id)); + if (missing.length === 0) return; + let cancelled = false; + void fetchIngredientsByIds(missing).then((list) => { + if (cancelled) return; + setResolved((prev) => { + const next = new Map(prev); + for (const ing of list) next.set(ing.id, ing); + return next; + }); + }); + return () => { + cancelled = true; + }; + // resolved è intenzionalmente fuori: vogliamo solo reagire ai bannati cambiati + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]); + + // Debounce ricerca per nome. + useEffect(() => { + const q = query.trim(); + if (q.length < 2) { + setResults([]); + return; + } + setSearching(true); + const timer = setTimeout(() => { + void searchIngredients(q).then((list) => { + setResults(list); + setSearching(false); + }); + }, 250); + return () => clearTimeout(timer); + }, [query]); + + function add(ing: IngredientView): void { + if (value.includes(ing.id)) return; + if (value.length >= 50) return; + setResolved((prev) => new Map(prev).set(ing.id, ing)); + onChange([...value, ing.id]); + setQuery(''); + setResults([]); + } + function remove(id: string): void { + onChange(value.filter((x) => x !== id)); + } + + return ( +
+ + {t('bannedIngredientsTitle')} + +

+ {t('bannedIngredientsHint')} +

+
+ setQuery(e.target.value)} + placeholder={t('bannedIngredientsSearchPlaceholder')} + maxLength={80} + className="border-ink/30 focus:border-ink font-display text-ink w-full border-b bg-transparent py-2 outline-none" + /> + {results.length > 0 ? ( +
    + {results.map((ing) => ( +
  • + +
  • + ))} +
+ ) : null} + {searching && results.length === 0 ? ( +

+ {t('bannedIngredientsSearching')} +

+ ) : null} +
+ {value.length > 0 ? ( +
+ {value.map((id) => { + const ing = resolved.get(id); + return ( + + {ing?.name ?? id} + + + ); + })} +
+ ) : ( +

{t('bannedIngredientsEmpty')}

+ )} + {value.length >= 50 ? ( +

{t('bannedIngredientsCap')}

+ ) : null} +
+ ); +} diff --git a/packages/db/prisma/migrations/20260506153325_add_banned_ingredient_ids/migration.sql b/packages/db/prisma/migrations/20260506153325_add_banned_ingredient_ids/migration.sql new file mode 100644 index 0000000..f0cf674 --- /dev/null +++ b/packages/db/prisma/migrations/20260506153325_add_banned_ingredient_ids/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "preferences" ADD COLUMN "banned_ingredient_ids" TEXT[] DEFAULT ARRAY[]::TEXT[]; diff --git a/packages/db/prisma/migrations/20260506154003_add_achievements/migration.sql b/packages/db/prisma/migrations/20260506154003_add_achievements/migration.sql new file mode 100644 index 0000000..d99523d --- /dev/null +++ b/packages/db/prisma/migrations/20260506154003_add_achievements/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "achievements" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "unlocked_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "achievements_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "achievements_user_id_idx" ON "achievements"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "achievements_user_id_key_key" ON "achievements"("user_id", "key"); + +-- AddForeignKey +ALTER TABLE "achievements" ADD CONSTRAINT "achievements_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 55d670b..a175d90 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -104,10 +104,27 @@ model User { fastEvents FastEvent[] mealPlans MealPlan[] deviceTokens DeviceToken[] + achievements Achievement[] @@map("users") } +// PRD §6 — sistema achievement: badge sbloccati man mano che l'utente +// raggiunge milestone (prima pesata, primo digiuno, primo piano, ecc.). +// `key` identifica univocamente l'achievement (vedi packages/shared). +model Achievement { + id String @id @default(cuid()) + userId String @map("user_id") + key String + unlockedAt DateTime @default(now()) @map("unlocked_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, key]) + @@index([userId]) + @@map("achievements") +} + // Better Auth — sessione utente firmata. model Session { id String @id @default(cuid()) @@ -223,6 +240,9 @@ model Preferences { // PRD §5.1 — pasti/giorno preferiti (1..4). Se null, fallback al default. // Il fastingProtocol ha precedenza: se attivo definisce lui la struttura. mealsPerDay Int? @map("meals_per_day") + // PRD §6 — esclusioni granulari per ingredient (override/aggiunte rispetto + // ai gruppi exclusionGroups). Memorizzate come array di Ingredient.id. + bannedIngredientIds String[] @default([]) @map("banned_ingredient_ids") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/packages/shared/src/achievements/definitions.ts b/packages/shared/src/achievements/definitions.ts new file mode 100644 index 0000000..8890a8c --- /dev/null +++ b/packages/shared/src/achievements/definitions.ts @@ -0,0 +1,53 @@ +// PRD §6 — sistema achievement: tassonomia ufficiale. +// Le `key` sono stabili: una volta esposte agli utenti non si rinominano. Per +// dismettere un achievement, lasciare la chiave qui (così il record storico +// continua a risolversi) e rimuoverla dal set "attivi". + +export type AchievementKey = + | 'first_weigh_in' + | 'first_plan' + | 'first_fast_complete' + | 'ten_meals_consumed'; + +export interface AchievementDefinition { + key: AchievementKey; + /** Etichetta corta da mostrare nel badge. */ + title: string; + /** Una riga di descrizione di cosa serve per sbloccarlo. */ + hint: string; + /** Categoria visiva — utile per raggruppare in UI. */ + category: 'tracking' | 'plan' | 'fasting'; +} + +// Set ordinato per la visualizzazione in UI: i più "tutorial" prima. +export const ACHIEVEMENTS: ReadonlyArray = [ + { + key: 'first_weigh_in', + title: 'Prima pesata', + hint: 'Hai registrato il tuo peso per la prima volta.', + category: 'tracking', + }, + { + key: 'first_plan', + title: 'Primo piano', + hint: 'Hai generato il tuo primo piano settimanale.', + category: 'plan', + }, + { + key: 'first_fast_complete', + title: 'Primo digiuno completato', + hint: 'Hai portato a termine la tua prima sessione di digiuno.', + category: 'fasting', + }, + { + key: 'ten_meals_consumed', + title: 'Dieci pasti spuntati', + hint: 'Hai marcato come consumati 10 pasti dal tuo piano.', + category: 'plan', + }, +]; + +export const ACHIEVEMENT_KEYS: ReadonlyArray = ACHIEVEMENTS.map((a) => a.key); + +// Soglia singola per ten_meals_consumed — esposta come costante per l'eval. +export const TEN_MEALS_THRESHOLD = 10; diff --git a/packages/shared/src/achievements/evaluator.test.ts b/packages/shared/src/achievements/evaluator.test.ts new file mode 100644 index 0000000..95170ee --- /dev/null +++ b/packages/shared/src/achievements/evaluator.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { evaluateAchievements } from './evaluator.js'; + +describe('evaluateAchievements', () => { + it('returns nothing when all metrics are zero', () => { + expect( + evaluateAchievements({ + weightEntryCount: 0, + planCount: 0, + completedFastCount: 0, + consumedMealCount: 0, + }), + ).toEqual([]); + }); + + it('unlocks first-time achievements at threshold 1', () => { + expect( + evaluateAchievements({ + weightEntryCount: 1, + planCount: 1, + completedFastCount: 1, + consumedMealCount: 0, + }), + ).toEqual(['first_weigh_in', 'first_plan', 'first_fast_complete']); + }); + + it('unlocks ten_meals_consumed at exactly 10 consumed meals', () => { + const at9 = evaluateAchievements({ + weightEntryCount: 0, + planCount: 0, + completedFastCount: 0, + consumedMealCount: 9, + }); + expect(at9).not.toContain('ten_meals_consumed'); + const at10 = evaluateAchievements({ + weightEntryCount: 0, + planCount: 0, + completedFastCount: 0, + consumedMealCount: 10, + }); + expect(at10).toContain('ten_meals_consumed'); + }); +}); diff --git a/packages/shared/src/achievements/evaluator.ts b/packages/shared/src/achievements/evaluator.ts new file mode 100644 index 0000000..09c4d78 --- /dev/null +++ b/packages/shared/src/achievements/evaluator.ts @@ -0,0 +1,22 @@ +// PRD §6 — eval puro degli achievement. +// Non tocca persistenza: prende metriche già misurate e ritorna le `key` +// che dovrebbero risultare sbloccate. Idempotente — l'API decide cosa +// scrivere a DB confrontando con gli already-unlocked. + +import { TEN_MEALS_THRESHOLD, type AchievementKey } from './definitions.js'; + +export interface AchievementMetrics { + weightEntryCount: number; + planCount: number; + completedFastCount: number; + consumedMealCount: number; +} + +export function evaluateAchievements(metrics: AchievementMetrics): AchievementKey[] { + const unlocked: AchievementKey[] = []; + if (metrics.weightEntryCount >= 1) unlocked.push('first_weigh_in'); + if (metrics.planCount >= 1) unlocked.push('first_plan'); + if (metrics.completedFastCount >= 1) unlocked.push('first_fast_complete'); + if (metrics.consumedMealCount >= TEN_MEALS_THRESHOLD) unlocked.push('ten_meals_consumed'); + return unlocked; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index caf0a94..1c49db2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,5 @@ +export * from './achievements/definitions.js'; +export * from './achievements/evaluator.js'; export * from './medical/conditions.js'; export * from './planner/adherence.js'; export * from './notifications/schema.js'; diff --git a/packages/shared/src/planner/matchmaking.test.ts b/packages/shared/src/planner/matchmaking.test.ts index b7d0e53..bd9cad3 100644 --- a/packages/shared/src/planner/matchmaking.test.ts +++ b/packages/shared/src/planner/matchmaking.test.ts @@ -110,4 +110,22 @@ describe('matchMeals', () => { }); expect(out).toHaveLength(0); }); + + it('filters by banned ingredient ids', () => { + const withIds: RecipeCandidate[] = recipes.map((r) => ({ + ...r, + ingredientIds: r.id === 'r1' ? ['ing-egg', 'ing-spinach'] : ['ing-avocado'], + })); + const out = matchMeals({ + candidates: withIds, + meal: 'COLAZIONE', + phase: 1, + excludedTags: [], + bannedIngredientIds: ['ing-egg'], + dailyTarget: { kcal: 1900, proteinG: 130, fatG: 130, netCarbG: 25 }, + mealShare: DEFAULT_MEAL_SHARE, + }); + expect(out.find((r) => r.id === 'r1')).toBeUndefined(); + expect(out.find((r) => r.id === 'r2')).toBeDefined(); + }); }); diff --git a/packages/shared/src/planner/matchmaking.ts b/packages/shared/src/planner/matchmaking.ts index d840d16..952b247 100644 --- a/packages/shared/src/planner/matchmaking.ts +++ b/packages/shared/src/planner/matchmaking.ts @@ -15,6 +15,9 @@ export interface RecipeCandidate { fatG: number; netCarbG: number; exclusionTags: ReadonlyArray; + // PRD §6 — ingredient ids della ricetta. Usati per applicare le esclusioni + // granulari per singolo ingrediente (`bannedIngredientIds`). + ingredientIds?: ReadonlyArray; phases: ReadonlyArray<1 | 2 | 3>; prepMinutes?: number; } @@ -31,6 +34,9 @@ export interface MatchOptions { meal: RecipeCandidate['category']; phase: 1 | 2 | 3; excludedTags: ReadonlyArray; + // PRD §6 — ids di Ingredient che l'utente ha bandito espressamente. + // Una ricetta è scartata se contiene almeno uno di questi ingredients. + bannedIngredientIds?: ReadonlyArray; recentlyConsumedIds?: ReadonlyArray; // Macros già consumati nello stesso giorno; usati per bilanciare le scelte // successive (la cena tiene conto di colazione/pranzo/spuntino). @@ -74,6 +80,7 @@ export function matchMeals(opts: MatchOptions): MatchResult[] { const recentSet = new Set(opts.recentlyConsumedIds ?? []); const exclusionSet = new Set(opts.excludedTags); + const bannedSet = new Set(opts.bannedIngredientIds ?? []); const results: MatchResult[] = []; @@ -81,6 +88,8 @@ export function matchMeals(opts: MatchOptions): MatchResult[] { if (r.category !== opts.meal) continue; if (!r.phases.includes(opts.phase)) continue; if (r.exclusionTags.some((tag) => exclusionSet.has(tag))) continue; + if (bannedSet.size > 0 && r.ingredientIds && r.ingredientIds.some((id) => bannedSet.has(id))) + continue; const dKcal = (r.kcal - mealTarget.kcal) / Math.max(1, mealTarget.kcal); const dPro = (r.proteinG - mealTarget.proteinG) / Math.max(1, mealTarget.proteinG); diff --git a/packages/shared/src/preferences/schema.ts b/packages/shared/src/preferences/schema.ts index 8a72b0c..56e10fb 100644 --- a/packages/shared/src/preferences/schema.ts +++ b/packages/shared/src/preferences/schema.ts @@ -53,6 +53,9 @@ export const preferencesPatchSchema = z sessionMinutes: z.coerce.number().int().min(10).max(240).nullable().optional(), // Pasti/giorno preferiti. Il fastingProtocol ha precedenza. mealsPerDay: z.coerce.number().int().min(1).max(4).nullable().optional(), + // PRD §6 — esclusioni granulari su singoli ingredient. Cap a 50 per evitare + // di soffocare il matchmaking (in pratica più di così è una dieta diversa). + bannedIngredientIds: z.array(z.string().min(1).max(120)).max(50).optional(), }) .strict();