feat: §6 Gruppo B residuo — pannello macros, esclusioni granulari, achievement
- Plan: pannello macros laterale "oggi" con consumati/pianificati vs target (kcal/P/F/C). Sticky su lg, in-flow su mobile. Profile/derived ora include kcalTarget e i macro target (BMR aggiustato per condizioni e diet history). - Preferenze: nuova sezione "Ingredienti specifici da evitare" con ricerca debounced sul catalogo (cap 50). Schema Preferences.bannedIngredientIds + matchmaking che scarta ricette contenenti ingredient bandito (test unit). - Achievement: tabella Achievement (userId+key unique), 4 traguardi iniziali (first_weigh_in, first_plan, first_fast_complete, ten_meals_consumed). Service idempotente eval+persist agganciato a 4 trigger (weight POST, plan POST, fast PATCH→COMPLETED, slot consumed). Push notification best-effort all'unlock. Pannello badge nel profilo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3b895191f5
commit
d17c5ef869
29 files changed
+1048
-86
No files matched your search
@@ -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<FastifyInstance> {
|
||||
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) => {
|
||||
|
||||
@@ -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<string>(ACHIEVEMENT_KEYS);
|
||||
const unlocked = rows
|
||||
.filter((r) => validKeys.has(r.key))
|
||||
.map((r) => ({ key: r.key as AchievementKey, unlockedAt: r.unlockedAt.toISOString() }));
|
||||
return { unlocked };
|
||||
});
|
||||
};
|
||||
@@ -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<UnlockOutput> {
|
||||
// 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<AchievementKey, { title: string; body: string }> = {
|
||||
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<void> {
|
||||
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 */
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string>();
|
||||
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<string>();
|
||||
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,
|
||||
|
||||
@@ -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<string, unknown> = {};
|
||||
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 };
|
||||
});
|
||||
};
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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.",
|
||||
|
||||
@@ -740,5 +740,6 @@ function toFormValues(view: PreferencesView): PreferencesValues {
|
||||
trainingType: view.trainingType as PreferencesValues['trainingType'],
|
||||
sessionMinutes: view.sessionMinutes,
|
||||
mealsPerDay: view.mealsPerDay,
|
||||
bannedIngredientIds: view.bannedIngredientIds ?? [],
|
||||
};
|
||||
}
|
||||
@@ -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<CurrentPlan | null> {
|
||||
return data.plan;
|
||||
}
|
||||
|
||||
export async function fetchDailyTarget(): Promise<DailyTarget | null> {
|
||||
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 `<form action={regeneratePlan}>`. La firma è
|
||||
// `(FormData) => Promise<void>` per allinearsi al type richiesto da React 18.
|
||||
export async function regeneratePlan(_formData?: FormData): Promise<void> {
|
||||
|
||||
@@ -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 <PlanPageContent plan={plan} />;
|
||||
return <PlanPageContent plan={plan} dailyTarget={dailyTarget} />;
|
||||
}
|
||||
|
||||
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<ReturnType<typeof fetchCurrentPlan>> }) {
|
||||
function PlanPageContent({
|
||||
plan,
|
||||
dailyTarget,
|
||||
}: {
|
||||
plan: Awaited<ReturnType<typeof fetchCurrentPlan>>;
|
||||
dailyTarget: Awaited<ReturnType<typeof fetchDailyTarget>>;
|
||||
}) {
|
||||
const t = useTranslations('Plan');
|
||||
|
||||
return (
|
||||
@@ -114,7 +120,7 @@ function PlanPageContent({ plan }: { plan: Awaited<ReturnType<typeof fetchCurren
|
||||
|
||||
{plan ? (
|
||||
<div className="animate-fade-up [animation-delay:480ms]">
|
||||
<PlanWeek plan={plan} />
|
||||
<PlanWeek plan={plan} dailyTarget={dailyTarget} />
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
@@ -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<number, PlanSlot[]>();
|
||||
@@ -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 (
|
||||
<div className="space-y-14">
|
||||
<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-5">
|
||||
<MacroSummary label={t('avgKcal')} value={weekAvg.kcal} unit="kcal" big />
|
||||
<MacroSummary label={t('avgProtein')} value={weekAvg.proteinG} unit="g" />
|
||||
<MacroSummary label={t('avgFat')} value={weekAvg.fatG} unit="g" />
|
||||
<MacroSummary label={t('avgNetCarb')} value={weekAvg.netCarbG} unit="g" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="editorial-eyebrow">{t('adherence')}</span>
|
||||
<span className="text-ink font-mono text-3xl font-medium tabular-nums">
|
||||
{adherence.pastSlots === 0 ? '—' : `${Math.round(adherence.rate * 100)}%`}
|
||||
</span>
|
||||
{adherence.pastSlots > 0 ? (
|
||||
<span className="text-ink-soft font-mono text-[10px] uppercase tracking-widest">
|
||||
{adherence.consumedSlots}/{adherence.pastSlots}
|
||||
<div className="grid gap-10 lg:grid-cols-[1fr_18rem]">
|
||||
<div className="space-y-14 lg:col-start-1">
|
||||
<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-5">
|
||||
<MacroSummary label={t('avgKcal')} value={weekAvg.kcal} unit="kcal" big />
|
||||
<MacroSummary label={t('avgProtein')} value={weekAvg.proteinG} unit="g" />
|
||||
<MacroSummary label={t('avgFat')} value={weekAvg.fatG} unit="g" />
|
||||
<MacroSummary label={t('avgNetCarb')} value={weekAvg.netCarbG} unit="g" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="editorial-eyebrow">{t('adherence')}</span>
|
||||
<span className="text-ink font-mono text-3xl font-medium tabular-nums">
|
||||
{adherence.pastSlots === 0 ? '—' : `${Math.round(adherence.rate * 100)}%`}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{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 (
|
||||
<section key={dayKey} aria-labelledby={`day-${dayKey}`} className="space-y-5">
|
||||
<header className="border-ink/15 grid items-baseline gap-2 border-b pb-3 sm:grid-cols-[auto_1fr_auto]">
|
||||
<span className="font-display text-pomodoro text-xl font-medium italic leading-none">
|
||||
{DAY_NUMERAL[idx]}
|
||||
{adherence.pastSlots > 0 ? (
|
||||
<span className="text-ink-soft font-mono text-[10px] uppercase tracking-widest">
|
||||
{adherence.consumedSlots}/{adherence.pastSlots}
|
||||
</span>
|
||||
<h2
|
||||
id={`day-${dayKey}`}
|
||||
className="font-display text-ink text-2xl font-medium leading-none tracking-tight sm:text-3xl"
|
||||
>
|
||||
{t(`days.${dayKey}`)}
|
||||
{isFastingDay ? (
|
||||
<span className="font-display text-ink-dim ml-3 text-base italic">
|
||||
· {t('fastingDay')}
|
||||
</span>
|
||||
) : isLightDay ? (
|
||||
<span className="font-display text-ink-dim ml-3 text-base italic">
|
||||
· {t('lightDay')}
|
||||
</span>
|
||||
) : null}
|
||||
</h2>
|
||||
<span className="text-ink-soft font-mono text-[11px] uppercase tracking-widest">
|
||||
{isFastingDay
|
||||
? '— kcal'
|
||||
: `${Math.round(dayMacros.kcal)} kcal · P ${Math.round(dayMacros.proteinG)} · G ${Math.round(dayMacros.fatG)} · C ${Math.round(dayMacros.netCarbG)}`}
|
||||
</span>
|
||||
</header>
|
||||
{isFastingDay ? (
|
||||
<p className="font-display text-ink-soft text-base italic leading-snug">
|
||||
{t('fastingDayHint')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-ink/10 grid grid-cols-1 divide-y sm:grid-cols-2 sm:gap-x-8 sm:divide-y-0 lg:grid-cols-4 lg:gap-x-6">
|
||||
{slots.map((slot, slotIdx) => (
|
||||
<SlotCard key={slot.id} slot={slot} index={slotIdx} canFreeMeal={isPhase3} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{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 (
|
||||
<section key={dayKey} aria-labelledby={`day-${dayKey}`} className="space-y-5">
|
||||
<header className="border-ink/15 grid items-baseline gap-2 border-b pb-3 sm:grid-cols-[auto_1fr_auto]">
|
||||
<span className="font-display text-pomodoro text-xl font-medium italic leading-none">
|
||||
{DAY_NUMERAL[idx]}
|
||||
</span>
|
||||
<h2
|
||||
id={`day-${dayKey}`}
|
||||
className="font-display text-ink text-2xl font-medium leading-none tracking-tight sm:text-3xl"
|
||||
>
|
||||
{t(`days.${dayKey}`)}
|
||||
{isFastingDay ? (
|
||||
<span className="font-display text-ink-dim ml-3 text-base italic">
|
||||
· {t('fastingDay')}
|
||||
</span>
|
||||
) : isLightDay ? (
|
||||
<span className="font-display text-ink-dim ml-3 text-base italic">
|
||||
· {t('lightDay')}
|
||||
</span>
|
||||
) : null}
|
||||
</h2>
|
||||
<span className="text-ink-soft font-mono text-[11px] uppercase tracking-widest">
|
||||
{isFastingDay
|
||||
? '— kcal'
|
||||
: `${Math.round(dayMacros.kcal)} kcal · P ${Math.round(dayMacros.proteinG)} · G ${Math.round(dayMacros.fatG)} · C ${Math.round(dayMacros.netCarbG)}`}
|
||||
</span>
|
||||
</header>
|
||||
{isFastingDay ? (
|
||||
<p className="font-display text-ink-soft text-base italic leading-snug">
|
||||
{t('fastingDayHint')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-ink/10 grid grid-cols-1 divide-y sm:grid-cols-2 sm:gap-x-8 sm:divide-y-0 lg:grid-cols-4 lg:gap-x-6">
|
||||
{slots.map((slot, slotIdx) => (
|
||||
<SlotCard key={slot.id} slot={slot} index={slotIdx} canFreeMeal={isPhase3} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<aside className="lg:col-start-2 lg:row-start-1">
|
||||
<TodayMacrosPanel
|
||||
consumed={todayConsumed}
|
||||
planned={todayPlanned}
|
||||
target={dailyTarget}
|
||||
hasSlots={todaySlots.length > 0}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="border-ink/15 bg-carta-light/40 border p-5 lg:sticky lg:top-6">
|
||||
<p className="editorial-eyebrow">{t('todayPanelTitle')}</p>
|
||||
<p className="font-display text-ink-soft mt-3 text-base italic leading-snug">
|
||||
{t('todayPanelFasting')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-ink/15 bg-carta-light/40 space-y-5 border p-5 lg:sticky lg:top-6">
|
||||
<div>
|
||||
<p className="editorial-eyebrow">{t('todayPanelTitle')}</p>
|
||||
{target ? (
|
||||
<p className="text-ink-soft mt-1 font-mono text-[10px] uppercase tracking-widest">
|
||||
{t('todayPanelTargetHint')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<MacroBar
|
||||
label={t('avgKcal')}
|
||||
consumed={consumed.kcal}
|
||||
planned={planned.kcal}
|
||||
target={target?.kcal}
|
||||
unit="kcal"
|
||||
/>
|
||||
<MacroBar
|
||||
label={t('avgProtein')}
|
||||
consumed={consumed.proteinG}
|
||||
planned={planned.proteinG}
|
||||
target={target?.proteinG}
|
||||
unit="g"
|
||||
/>
|
||||
<MacroBar
|
||||
label={t('avgFat')}
|
||||
consumed={consumed.fatG}
|
||||
planned={planned.fatG}
|
||||
target={target?.fatG}
|
||||
unit="g"
|
||||
/>
|
||||
<MacroBar
|
||||
label={t('avgNetCarb')}
|
||||
consumed={consumed.netCarbG}
|
||||
planned={planned.netCarbG}
|
||||
target={target?.netCarbG}
|
||||
unit="g"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="editorial-eyebrow">{label}</span>
|
||||
<span className="text-ink font-mono text-xs tabular-nums">
|
||||
{Math.round(consumed)}
|
||||
{target != null ? (
|
||||
<span className="text-ink-soft"> / {Math.round(target)}</span>
|
||||
) : (
|
||||
<span className="text-ink-soft"> / {Math.round(planned)}</span>
|
||||
)}
|
||||
<span className="font-display text-ink-soft ml-1 text-[10px] italic">{unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-ink/10 relative h-1.5 w-full overflow-hidden">
|
||||
<div
|
||||
className="bg-ink/30 absolute inset-y-0 left-0"
|
||||
style={{ width: `${plannedPct}%` }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className={`absolute inset-y-0 left-0 ${overTarget ? 'bg-pomodoro' : 'bg-oliva'}`}
|
||||
style={{ width: `${consumedPct}%` }}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MacroSummary({
|
||||
label,
|
||||
value,
|
||||
|
||||
@@ -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<UnlockedAchievement[]> {
|
||||
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;
|
||||
}
|
||||
@@ -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 (
|
||||
<section className="space-y-8">
|
||||
<header className="space-y-3">
|
||||
<p className="editorial-eyebrow">{t('eyebrow')}</p>
|
||||
<h2 className="font-display text-ink text-3xl font-medium leading-tight tracking-tight">
|
||||
{t('title')}
|
||||
</h2>
|
||||
<p className="font-display text-ink-soft max-w-xl text-base italic leading-snug">
|
||||
{t('subtitle', { got, total })}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ul className="grid gap-4 sm:grid-cols-2">
|
||||
{ACHIEVEMENTS.map((a) => {
|
||||
const at = map.get(a.key);
|
||||
const unlockedDate = at ? ITALIAN_DATE.format(new Date(at)) : null;
|
||||
return (
|
||||
<li
|
||||
key={a.key}
|
||||
className={`group flex flex-col gap-2 border p-5 transition-colors ${
|
||||
at ? 'border-pomodoro/40 bg-carta-light' : 'border-ink/15 bg-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span
|
||||
className={`font-mono text-[10px] uppercase tracking-widest ${
|
||||
at ? 'text-pomodoro' : 'text-ink-dim'
|
||||
}`}
|
||||
>
|
||||
{t(`category.${a.category}`)}
|
||||
</span>
|
||||
{at ? (
|
||||
<span className="text-ink-soft font-mono text-[10px] tracking-widest">
|
||||
{unlockedDate}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-ink-dim font-mono text-[10px] uppercase tracking-widest">
|
||||
{t('locked')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={`font-display text-xl font-medium leading-tight ${
|
||||
at ? 'text-ink' : 'text-ink-dim'
|
||||
}`}
|
||||
>
|
||||
{t(`key.${a.key}.title`)}
|
||||
</p>
|
||||
<p
|
||||
className={`font-display text-sm italic leading-snug ${
|
||||
at ? 'text-ink-soft' : 'text-ink-dim'
|
||||
}`}
|
||||
>
|
||||
{t(`key.${a.key}.hint`)}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof ProfileForm>[0]['initial'];
|
||||
const notifications = await fetchNotificationConfig();
|
||||
const preferences = await fetchPreferences();
|
||||
const achievements = await fetchAchievements();
|
||||
|
||||
return (
|
||||
<ProfilePageContent initial={profile} notifications={notifications} preferences={preferences} />
|
||||
<ProfilePageContent
|
||||
initial={profile}
|
||||
notifications={notifications}
|
||||
preferences={preferences}
|
||||
achievements={achievements}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,10 +46,12 @@ function ProfilePageContent({
|
||||
initial,
|
||||
notifications,
|
||||
preferences,
|
||||
achievements,
|
||||
}: {
|
||||
initial: Parameters<typeof ProfileForm>[0]['initial'];
|
||||
notifications: Awaited<ReturnType<typeof fetchNotificationConfig>>;
|
||||
preferences: Awaited<ReturnType<typeof fetchPreferences>>;
|
||||
achievements: Awaited<ReturnType<typeof fetchAchievements>>;
|
||||
}) {
|
||||
const t = useTranslations('Profile');
|
||||
|
||||
@@ -79,6 +89,12 @@ function ProfilePageContent({
|
||||
<div className="rule animate-rule-in my-16 [animation-delay:780ms]" />
|
||||
|
||||
<div className="animate-fade-up [animation-delay:840ms]">
|
||||
<AchievementsPanel unlocked={achievements} />
|
||||
</div>
|
||||
|
||||
<div className="rule animate-rule-in my-16 [animation-delay:900ms]" />
|
||||
|
||||
<div className="animate-fade-up [animation-delay:960ms]">
|
||||
<DataPanel />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -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<IngredientView[]> {
|
||||
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<IngredientView[]> {
|
||||
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<PreferencesView | null> {
|
||||
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
}
|
||||
@@ -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({
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<BannedIngredientsField
|
||||
value={values.bannedIngredientIds}
|
||||
onChange={(next) => setValues((prev) => ({ ...prev, bannedIngredientIds: next }))}
|
||||
/>
|
||||
|
||||
<fieldset className="space-y-5">
|
||||
<legend className="font-display text-ink text-2xl font-medium leading-tight tracking-tight">
|
||||
{t('cuisinesTitle')}
|
||||
@@ -380,3 +393,142 @@ function Chip({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function BannedIngredientsField({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
}) {
|
||||
const t = useTranslations('Preferences');
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<IngredientView[]>([]);
|
||||
const [resolved, setResolved] = useState<Map<string, IngredientView>>(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 (
|
||||
<fieldset className="space-y-5">
|
||||
<legend className="font-display text-ink text-2xl font-medium leading-tight tracking-tight">
|
||||
{t('bannedIngredientsTitle')}
|
||||
</legend>
|
||||
<p className="font-display text-ink-soft text-base italic leading-snug">
|
||||
{t('bannedIngredientsHint')}
|
||||
</p>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => 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 ? (
|
||||
<ul className="border-ink/15 bg-carta-light absolute z-10 mt-1 max-h-60 w-full overflow-auto border shadow-sm">
|
||||
{results.map((ing) => (
|
||||
<li key={ing.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => add(ing)}
|
||||
disabled={value.includes(ing.id) || value.length >= 50}
|
||||
className="hover:bg-ink/5 font-display text-ink flex w-full items-baseline justify-between gap-3 px-3 py-2 text-left text-sm leading-tight disabled:opacity-40"
|
||||
>
|
||||
<span>{ing.name}</span>
|
||||
{ing.category ? (
|
||||
<span className="text-ink-dim font-mono text-[10px] uppercase tracking-widest">
|
||||
{ing.category}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{searching && results.length === 0 ? (
|
||||
<p className="text-ink-dim mt-1 font-mono text-[10px] uppercase tracking-widest">
|
||||
{t('bannedIngredientsSearching')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{value.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{value.map((id) => {
|
||||
const ing = resolved.get(id);
|
||||
return (
|
||||
<span
|
||||
key={id}
|
||||
className="border-pomodoro/40 text-pomodoro inline-flex items-center gap-2 rounded-full border px-4 py-2"
|
||||
>
|
||||
<span className="font-display text-sm leading-tight">{ing?.name ?? id}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(id)}
|
||||
aria-label={t('bannedIngredientsRemove')}
|
||||
title={t('bannedIngredientsRemove')}
|
||||
className="hover:text-ink font-mono text-xs leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="font-display text-ink-dim text-sm italic">{t('bannedIngredientsEmpty')}</p>
|
||||
)}
|
||||
{value.length >= 50 ? (
|
||||
<p className="font-display text-ink-soft text-xs italic">{t('bannedIngredientsCap')}</p>
|
||||
) : null}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "preferences" ADD COLUMN "banned_ingredient_ids" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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<AchievementDefinition> = [
|
||||
{
|
||||
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<AchievementKey> = ACHIEVEMENTS.map((a) => a.key);
|
||||
|
||||
// Soglia singola per ten_meals_consumed — esposta come costante per l'eval.
|
||||
export const TEN_MEALS_THRESHOLD = 10;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,9 @@ export interface RecipeCandidate {
|
||||
fatG: number;
|
||||
netCarbG: number;
|
||||
exclusionTags: ReadonlyArray<string>;
|
||||
// PRD §6 — ingredient ids della ricetta. Usati per applicare le esclusioni
|
||||
// granulari per singolo ingrediente (`bannedIngredientIds`).
|
||||
ingredientIds?: ReadonlyArray<string>;
|
||||
phases: ReadonlyArray<1 | 2 | 3>;
|
||||
prepMinutes?: number;
|
||||
}
|
||||
@@ -31,6 +34,9 @@ export interface MatchOptions {
|
||||
meal: RecipeCandidate['category'];
|
||||
phase: 1 | 2 | 3;
|
||||
excludedTags: ReadonlyArray<string>;
|
||||
// PRD §6 — ids di Ingredient che l'utente ha bandito espressamente.
|
||||
// Una ricetta è scartata se contiene almeno uno di questi ingredients.
|
||||
bannedIngredientIds?: ReadonlyArray<string>;
|
||||
recentlyConsumedIds?: ReadonlyArray<string>;
|
||||
// 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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user