feat: V1 tracking endpoints + meal plan generation (PRD §5.1, §5.2, §5.3)
@ketopath/shared:
- tracking/schema.ts — Zod schemas for WeightEntry input (weight, optional
measurements/notes/energy/sleep/hunger, photo URLs) and FastEvent start /
update, plus PROTOCOL_DEFAULT_MINUTES table
- planner/macros.ts — macrosForPhase(): protein 1.6-1.8g/kg, netCarb 25/60/120g
by phase, fat = remainder (PRD §9.3)
- planner/matchmaking.ts — pure matchMeals() that filters by exclusion tags,
phase compatibility and meal category, then scores by euclidean distance
from the meal's macro target with a 1.5 penalty for recently-consumed recipes;
DEFAULT_MEAL_SHARE constant (25/35/10/30 %)
- 5 new unit tests cover exclusion, phase, recency, topN, category mismatch
apps/api:
- modules/tracking/weight.routes.ts — GET /me/weight-entries (last 60),
POST /me/weight-entries with upsert on (userId, date); encrypted fields
serialised to/from JSON strings
- modules/tracking/fast.routes.ts — GET, POST (start) and PATCH (update) on
fast events; symptoms stored as encrypted JSON
- modules/plan/plan.routes.ts — POST /me/meal-plans:
- reads profile + preferences, computes BMR (Mifflin-St Jeor), TDEE, daily
kcal target with the phase-dependent deficit, then macros via macrosForPhase
- upserts a MealPlan rooted at Monday-of-this-week, wipes prior slots, and
fills 28 slots running matchMeals per (day, meal) with a recent-2-day
rolling exclusion list to keep variety
- selected recipe + 4 alternatives per slot
- GET /me/meal-plans/current returns the active plan with selected/alternatives
@ketopath/db:
- prisma/seed-recipes.ts — 16 italian keto recipes (4 per meal type) with
estimated macros per serving and phase compatibility
- prisma/seed.ts — idempotent insertion (skip on existing name match)
Verified end-to-end with sign-up → create profile (Michele PRD persona) →
generate plan: 28 slots filled, daily target 1399 kcal / 137 P / 83 F / 25 C,
matchmaking distributes 4 different breakfasts across the first 4 days as
the recent-consumed penalty kicks in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
0d2d1f49c7
commit
4b6c3f6ae9
12 files changed
+854
-3
No files matched your search
@@ -9,7 +9,10 @@ import { Sentry } from './lib/sentry.js';
|
|||||||
import { dbRoutes } from './modules/db/db.routes.js';
|
import { dbRoutes } from './modules/db/db.routes.js';
|
||||||
import { healthRoutes } from './modules/health/health.routes.js';
|
import { healthRoutes } from './modules/health/health.routes.js';
|
||||||
import { meRoutes } from './modules/me/me.routes.js';
|
import { meRoutes } from './modules/me/me.routes.js';
|
||||||
|
import { planRoutes } from './modules/plan/plan.routes.js';
|
||||||
import { profileRoutes } from './modules/profile/profile.routes.js';
|
import { profileRoutes } from './modules/profile/profile.routes.js';
|
||||||
|
import { fastRoutes } from './modules/tracking/fast.routes.js';
|
||||||
|
import { weightRoutes } from './modules/tracking/weight.routes.js';
|
||||||
import { authPlugin } from './plugins/auth.js';
|
import { authPlugin } from './plugins/auth.js';
|
||||||
import { prismaPlugin } from './plugins/prisma.js';
|
import { prismaPlugin } from './plugins/prisma.js';
|
||||||
|
|
||||||
@@ -46,6 +49,9 @@ export async function buildApp(): Promise<FastifyInstance> {
|
|||||||
await app.register(dbRoutes);
|
await app.register(dbRoutes);
|
||||||
await app.register(meRoutes);
|
await app.register(meRoutes);
|
||||||
await app.register(profileRoutes);
|
await app.register(profileRoutes);
|
||||||
|
await app.register(weightRoutes);
|
||||||
|
await app.register(fastRoutes);
|
||||||
|
await app.register(planRoutes);
|
||||||
|
|
||||||
if (env.SENTRY_DSN) {
|
if (env.SENTRY_DSN) {
|
||||||
app.setErrorHandler((err, request, reply) => {
|
app.setErrorHandler((err, request, reply) => {
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_MEAL_SHARE,
|
||||||
|
calculateBmr,
|
||||||
|
calculateTdee,
|
||||||
|
macrosForPhase,
|
||||||
|
matchMeals,
|
||||||
|
type RecipeCandidate,
|
||||||
|
} from '@ketopath/shared';
|
||||||
|
import type { FastifyPluginAsync } from 'fastify';
|
||||||
|
|
||||||
|
import { requireAuth } from '../../plugins/auth.js';
|
||||||
|
|
||||||
|
const MEALS = ['COLAZIONE', 'PRANZO', 'SPUNTINO', 'CENA'] as const;
|
||||||
|
|
||||||
|
function startOfWeek(d: Date): Date {
|
||||||
|
const out = new Date(d);
|
||||||
|
const day = out.getDay(); // 0 = Sunday
|
||||||
|
const diff = day === 0 ? 6 : day - 1; // Monday-anchored week
|
||||||
|
out.setDate(out.getDate() - diff);
|
||||||
|
out.setHours(0, 0, 0, 0);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function phaseToInt(p: string): 1 | 2 | 3 {
|
||||||
|
if (p === 'INTENSIVE') return 1;
|
||||||
|
if (p === 'TRANSITION') return 2;
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||||
|
fastify.post('/me/meal-plans', { preHandler: requireAuth() }, async (request, reply) => {
|
||||||
|
const userId = request.user!.id;
|
||||||
|
|
||||||
|
const profile = await fastify.prisma.profile.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
include: { user: { include: { preferences: true } } },
|
||||||
|
});
|
||||||
|
if (!profile) return reply.code(409).send({ error: 'profile_required' });
|
||||||
|
|
||||||
|
const recipes = await fastify.prisma.recipe.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
category: true,
|
||||||
|
kcal: true,
|
||||||
|
proteinG: true,
|
||||||
|
fatG: true,
|
||||||
|
netCarbG: true,
|
||||||
|
phases: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (recipes.length === 0) {
|
||||||
|
return reply.code(409).send({ error: 'recipe_catalog_empty' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates: RecipeCandidate[] = recipes.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
category: r.category,
|
||||||
|
kcal: r.kcal,
|
||||||
|
proteinG: r.proteinG,
|
||||||
|
fatG: r.fatG,
|
||||||
|
netCarbG: r.netCarbG,
|
||||||
|
exclusionTags: [],
|
||||||
|
phases: r.phases.filter((p): p is 1 | 2 | 3 => p === 1 || p === 2 || p === 3),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const weightCurrentKg = Number(profile.weightCurrentKg);
|
||||||
|
const bmr = calculateBmr({
|
||||||
|
weightKg: weightCurrentKg,
|
||||||
|
heightCm: profile.heightCm,
|
||||||
|
ageYears: profile.age,
|
||||||
|
gender: profile.gender,
|
||||||
|
});
|
||||||
|
const tdee = calculateTdee(bmr, profile.activityLevel);
|
||||||
|
const phaseInt = phaseToInt(profile.currentPhase);
|
||||||
|
// Deficit base solo in fase 1; in fase 2/3 si avvicina a TDEE.
|
||||||
|
const kcalTarget = Math.round(phaseInt === 1 ? tdee - 500 : phaseInt === 2 ? tdee - 200 : tdee);
|
||||||
|
const dailyTarget = macrosForPhase({ kcalTarget, weightKg: weightCurrentKg, phase: phaseInt });
|
||||||
|
const exclusions = profile.user.preferences?.exclusions ?? [];
|
||||||
|
|
||||||
|
const weekStart = startOfWeek(new Date());
|
||||||
|
|
||||||
|
const plan = await fastify.prisma.mealPlan.upsert({
|
||||||
|
where: { userId_weekStart: { userId, weekStart } },
|
||||||
|
create: { userId, weekStart },
|
||||||
|
update: { generatedAt: new Date(), status: 'ACTIVE' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wipe existing slots before regenerating, in case the plan already existed.
|
||||||
|
await fastify.prisma.mealSlot.deleteMany({ where: { planId: plan.id } });
|
||||||
|
|
||||||
|
const plannedSlots: Array<{ day: number; meal: string; recipeId: string | null }> = [];
|
||||||
|
for (let day = 0; day < 7; day++) {
|
||||||
|
// Per ogni pasto, le ricette già scelte negli ultimi 2 giorni sono "recenti".
|
||||||
|
const recentlyConsumedIds: string[] = plannedSlots
|
||||||
|
.filter((s) => day - s.day <= 2 && s.recipeId)
|
||||||
|
.map((s) => s.recipeId!);
|
||||||
|
|
||||||
|
for (const meal of MEALS) {
|
||||||
|
const top = matchMeals({
|
||||||
|
candidates,
|
||||||
|
meal,
|
||||||
|
phase: phaseInt,
|
||||||
|
excludedTags: exclusions,
|
||||||
|
recentlyConsumedIds,
|
||||||
|
dailyTarget,
|
||||||
|
mealShare: DEFAULT_MEAL_SHARE,
|
||||||
|
topN: 5,
|
||||||
|
});
|
||||||
|
const selected = top[0];
|
||||||
|
await fastify.prisma.mealSlot.create({
|
||||||
|
data: {
|
||||||
|
planId: plan.id,
|
||||||
|
dayOfWeek: day,
|
||||||
|
meal,
|
||||||
|
recipeId: selected?.id ?? null,
|
||||||
|
alternatives: { connect: top.slice(1).map((r) => ({ id: r.id })) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
plannedSlots.push({ day, meal, recipeId: selected?.id ?? null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.code(201).send({
|
||||||
|
plan: {
|
||||||
|
id: plan.id,
|
||||||
|
weekStart: plan.weekStart.toISOString().slice(0, 10),
|
||||||
|
dailyTarget,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get('/me/meal-plans/current', { preHandler: requireAuth() }, async (request, reply) => {
|
||||||
|
const plan = await fastify.prisma.mealPlan.findFirst({
|
||||||
|
where: { userId: request.user!.id, status: 'ACTIVE' },
|
||||||
|
orderBy: { weekStart: 'desc' },
|
||||||
|
include: {
|
||||||
|
slots: {
|
||||||
|
include: {
|
||||||
|
selected: { select: { id: true, name: true, kcal: true } },
|
||||||
|
alternatives: { select: { id: true, name: true, kcal: true } },
|
||||||
|
},
|
||||||
|
orderBy: [{ dayOfWeek: 'asc' }, { meal: 'asc' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!plan) return reply.code(404).send({ error: 'plan_not_found' });
|
||||||
|
return {
|
||||||
|
plan: {
|
||||||
|
id: plan.id,
|
||||||
|
weekStart: plan.weekStart.toISOString().slice(0, 10),
|
||||||
|
slots: plan.slots,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
PROTOCOL_DEFAULT_MINUTES,
|
||||||
|
fastEventStartSchema,
|
||||||
|
fastEventUpdateSchema,
|
||||||
|
} from '@ketopath/shared';
|
||||||
|
import type { FastifyPluginAsync } from 'fastify';
|
||||||
|
|
||||||
|
import { requireAuth } from '../../plugins/auth.js';
|
||||||
|
|
||||||
|
export const fastRoutes: FastifyPluginAsync = async (fastify) => {
|
||||||
|
fastify.get('/me/fast-events', { preHandler: requireAuth() }, async (request) => {
|
||||||
|
const events = await fastify.prisma.fastEvent.findMany({
|
||||||
|
where: { userId: request.user!.id },
|
||||||
|
orderBy: { startedAt: 'desc' },
|
||||||
|
take: 30,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
events: events.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
protocol: e.protocol,
|
||||||
|
status: e.status,
|
||||||
|
startedAt: e.startedAt.toISOString(),
|
||||||
|
endedAt: e.endedAt?.toISOString() ?? null,
|
||||||
|
targetDuration: e.targetDuration,
|
||||||
|
symptoms: e.symptoms ? (JSON.parse(e.symptoms) as unknown) : null,
|
||||||
|
notes: e.notes,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post('/me/fast-events', { preHandler: requireAuth() }, async (request, reply) => {
|
||||||
|
const parsed = fastEventStartSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
const data = parsed.data;
|
||||||
|
const event = await fastify.prisma.fastEvent.create({
|
||||||
|
data: {
|
||||||
|
userId: request.user!.id,
|
||||||
|
protocol: data.protocol,
|
||||||
|
startedAt: data.startedAt ?? new Date(),
|
||||||
|
targetDuration: data.targetDuration ?? PROTOCOL_DEFAULT_MINUTES[data.protocol],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return reply.code(201).send({ event: { id: event.id } });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.patch('/me/fast-events/:id', { preHandler: requireAuth() }, async (request, reply) => {
|
||||||
|
const id = (request.params as { id: string }).id;
|
||||||
|
const parsed = fastEventUpdateSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
const data = parsed.data;
|
||||||
|
// Garantisce che l'utente possa aggiornare solo i propri eventi.
|
||||||
|
const owned = await fastify.prisma.fastEvent.findFirst({
|
||||||
|
where: { id, userId: request.user!.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!owned) return reply.code(404).send({ error: 'fast_event_not_found' });
|
||||||
|
|
||||||
|
const event = await fastify.prisma.fastEvent.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
endedAt: data.endedAt ?? undefined,
|
||||||
|
status: data.status ?? undefined,
|
||||||
|
symptoms: data.symptoms ? JSON.stringify(data.symptoms) : undefined,
|
||||||
|
notes: data.notes ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { event: { id: event.id, status: event.status } };
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { weightEntryInputSchema } from '@ketopath/shared';
|
||||||
|
import type { FastifyPluginAsync } from 'fastify';
|
||||||
|
|
||||||
|
import { requireAuth } from '../../plugins/auth.js';
|
||||||
|
|
||||||
|
export const weightRoutes: FastifyPluginAsync = async (fastify) => {
|
||||||
|
fastify.get('/me/weight-entries', { preHandler: requireAuth() }, async (request) => {
|
||||||
|
const entries = await fastify.prisma.weightEntry.findMany({
|
||||||
|
where: { userId: request.user!.id },
|
||||||
|
orderBy: { date: 'desc' },
|
||||||
|
take: 60,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
entries: entries.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
date: e.date.toISOString().slice(0, 10),
|
||||||
|
weightKg: Number(e.weightKg),
|
||||||
|
measurements: e.measurements ? (JSON.parse(e.measurements) as unknown) : null,
|
||||||
|
notes: e.notes,
|
||||||
|
energy: e.energy,
|
||||||
|
sleep: e.sleep,
|
||||||
|
hunger: e.hunger,
|
||||||
|
photos: e.photos,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post('/me/weight-entries', { preHandler: requireAuth() }, async (request, reply) => {
|
||||||
|
const parsed = weightEntryInputSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
const data = parsed.data;
|
||||||
|
const userId = request.user!.id;
|
||||||
|
|
||||||
|
const entry = await fastify.prisma.weightEntry.upsert({
|
||||||
|
where: { userId_date: { userId, date: data.date } },
|
||||||
|
create: {
|
||||||
|
userId,
|
||||||
|
date: data.date,
|
||||||
|
weightKg: String(data.weightKg),
|
||||||
|
measurements: data.measurements ? JSON.stringify(data.measurements) : null,
|
||||||
|
notes: data.notes ?? null,
|
||||||
|
energy: data.energy ?? null,
|
||||||
|
sleep: data.sleep ?? null,
|
||||||
|
hunger: data.hunger ?? null,
|
||||||
|
photos: data.photos ?? [],
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
weightKg: String(data.weightKg),
|
||||||
|
measurements: data.measurements ? JSON.stringify(data.measurements) : null,
|
||||||
|
notes: data.notes ?? null,
|
||||||
|
energy: data.energy ?? null,
|
||||||
|
sleep: data.sleep ?? null,
|
||||||
|
hunger: data.hunger ?? null,
|
||||||
|
photos: data.photos ?? [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply
|
||||||
|
.code(201)
|
||||||
|
.send({ entry: { id: entry.id, date: entry.date.toISOString().slice(0, 10) } });
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
// Seed minimo del catalogo ricette italiane keto/low-carb (PRD §5.1).
|
||||||
|
// 16 ricette distribuite su COLAZIONE/PRANZO/SPUNTINO/CENA, fasi 1/2/3.
|
||||||
|
// Macros stimati per porzione singola.
|
||||||
|
|
||||||
|
import type { Difficulty, MealCategory } from '@prisma/client';
|
||||||
|
|
||||||
|
export interface SeedRecipe {
|
||||||
|
name: string;
|
||||||
|
category: MealCategory;
|
||||||
|
description: string;
|
||||||
|
prepMinutes: number;
|
||||||
|
difficulty: Difficulty;
|
||||||
|
kcal: number;
|
||||||
|
proteinG: number;
|
||||||
|
fatG: number;
|
||||||
|
netCarbG: number;
|
||||||
|
phases: number[];
|
||||||
|
notesChef?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SEED_RECIPES: SeedRecipe[] = [
|
||||||
|
// COLAZIONE
|
||||||
|
{
|
||||||
|
name: 'Frittata di spinaci e parmigiano',
|
||||||
|
category: 'COLAZIONE',
|
||||||
|
description: 'Tre uova con spinaci saltati e parmigiano grattugiato.',
|
||||||
|
prepMinutes: 10,
|
||||||
|
difficulty: 'FACILE',
|
||||||
|
kcal: 360,
|
||||||
|
proteinG: 28,
|
||||||
|
fatG: 26,
|
||||||
|
netCarbG: 4,
|
||||||
|
phases: [1, 2, 3],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Yogurt greco con noci e cannella',
|
||||||
|
category: 'COLAZIONE',
|
||||||
|
description: 'Yogurt greco intero con un cucchiaio di noci tritate.',
|
||||||
|
prepMinutes: 3,
|
||||||
|
difficulty: 'FACILE',
|
||||||
|
kcal: 320,
|
||||||
|
proteinG: 18,
|
||||||
|
fatG: 22,
|
||||||
|
netCarbG: 8,
|
||||||
|
phases: [1, 2, 3],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Avocado toast keto su pane di mandorle',
|
||||||
|
category: 'COLAZIONE',
|
||||||
|
description: 'Mezzo avocado schiacciato su una fetta di pane proteico.',
|
||||||
|
prepMinutes: 5,
|
||||||
|
difficulty: 'FACILE',
|
||||||
|
kcal: 380,
|
||||||
|
proteinG: 12,
|
||||||
|
fatG: 30,
|
||||||
|
netCarbG: 6,
|
||||||
|
phases: [1, 2, 3],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Uova strapazzate con pancetta',
|
||||||
|
category: 'COLAZIONE',
|
||||||
|
description: 'Due uova strapazzate al burro con pancetta croccante.',
|
||||||
|
prepMinutes: 8,
|
||||||
|
difficulty: 'FACILE',
|
||||||
|
kcal: 410,
|
||||||
|
proteinG: 24,
|
||||||
|
fatG: 33,
|
||||||
|
netCarbG: 2,
|
||||||
|
phases: [1, 2, 3],
|
||||||
|
},
|
||||||
|
// PRANZO
|
||||||
|
{
|
||||||
|
name: 'Insalata di pollo, avocado e pomodorini',
|
||||||
|
category: 'PRANZO',
|
||||||
|
description: 'Petto di pollo grigliato, avocado, pomodorini, olio EVO.',
|
||||||
|
prepMinutes: 15,
|
||||||
|
difficulty: 'FACILE',
|
||||||
|
kcal: 540,
|
||||||
|
proteinG: 38,
|
||||||
|
fatG: 36,
|
||||||
|
netCarbG: 9,
|
||||||
|
phases: [1, 2, 3],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Salmone al forno con asparagi',
|
||||||
|
category: 'PRANZO',
|
||||||
|
description: 'Filetto di salmone con asparagi al forno e limone.',
|
||||||
|
prepMinutes: 20,
|
||||||
|
difficulty: 'FACILE',
|
||||||
|
kcal: 510,
|
||||||
|
proteinG: 36,
|
||||||
|
fatG: 35,
|
||||||
|
netCarbG: 6,
|
||||||
|
phases: [1, 2, 3],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Bistecca con zucchine grigliate',
|
||||||
|
category: 'PRANZO',
|
||||||
|
description: 'Tagliata di manzo con zucchine grigliate e olio EVO.',
|
||||||
|
prepMinutes: 18,
|
||||||
|
difficulty: 'MEDIA',
|
||||||
|
kcal: 580,
|
||||||
|
proteinG: 42,
|
||||||
|
fatG: 38,
|
||||||
|
netCarbG: 5,
|
||||||
|
phases: [1, 2, 3],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Insalatona con tonno e olive',
|
||||||
|
category: 'PRANZO',
|
||||||
|
description: "Insalata mista, tonno sott'olio, olive nere, capperi.",
|
||||||
|
prepMinutes: 10,
|
||||||
|
difficulty: 'FACILE',
|
||||||
|
kcal: 470,
|
||||||
|
proteinG: 32,
|
||||||
|
fatG: 32,
|
||||||
|
netCarbG: 7,
|
||||||
|
phases: [1, 2, 3],
|
||||||
|
},
|
||||||
|
// SPUNTINO
|
||||||
|
{
|
||||||
|
name: 'Mandorle al naturale',
|
||||||
|
category: 'SPUNTINO',
|
||||||
|
description: 'Una manciata di mandorle (~25 g).',
|
||||||
|
prepMinutes: 1,
|
||||||
|
difficulty: 'FACILE',
|
||||||
|
kcal: 150,
|
||||||
|
proteinG: 5,
|
||||||
|
fatG: 13,
|
||||||
|
netCarbG: 3,
|
||||||
|
phases: [1, 2, 3],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cubetti di parmigiano',
|
||||||
|
category: 'SPUNTINO',
|
||||||
|
description: '30 g di parmigiano stagionato 24 mesi.',
|
||||||
|
prepMinutes: 1,
|
||||||
|
difficulty: 'FACILE',
|
||||||
|
kcal: 120,
|
||||||
|
proteinG: 11,
|
||||||
|
fatG: 8,
|
||||||
|
netCarbG: 0,
|
||||||
|
phases: [1, 2, 3],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Olive verdi marinate',
|
||||||
|
category: 'SPUNTINO',
|
||||||
|
description: 'Una porzione di olive verdi (60 g) con erbe aromatiche.',
|
||||||
|
prepMinutes: 1,
|
||||||
|
difficulty: 'FACILE',
|
||||||
|
kcal: 90,
|
||||||
|
proteinG: 1,
|
||||||
|
fatG: 9,
|
||||||
|
netCarbG: 1,
|
||||||
|
phases: [1, 2, 3],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cetrioli con hummus di zucchine',
|
||||||
|
category: 'SPUNTINO',
|
||||||
|
description: 'Cetrioli a bastoncino con hummus light di zucchine.',
|
||||||
|
prepMinutes: 5,
|
||||||
|
difficulty: 'FACILE',
|
||||||
|
kcal: 110,
|
||||||
|
proteinG: 3,
|
||||||
|
fatG: 8,
|
||||||
|
netCarbG: 5,
|
||||||
|
phases: [2, 3],
|
||||||
|
},
|
||||||
|
// CENA
|
||||||
|
{
|
||||||
|
name: 'Branzino al cartoccio con verdure',
|
||||||
|
category: 'CENA',
|
||||||
|
description: 'Branzino al cartoccio con zucchine, carote e pomodorini.',
|
||||||
|
prepMinutes: 25,
|
||||||
|
difficulty: 'MEDIA',
|
||||||
|
kcal: 460,
|
||||||
|
proteinG: 38,
|
||||||
|
fatG: 28,
|
||||||
|
netCarbG: 7,
|
||||||
|
phases: [1, 2, 3],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Polpette di pollo e ricotta',
|
||||||
|
category: 'CENA',
|
||||||
|
description: 'Polpette al forno con ricotta, basilico e parmigiano.',
|
||||||
|
prepMinutes: 30,
|
||||||
|
difficulty: 'MEDIA',
|
||||||
|
kcal: 490,
|
||||||
|
proteinG: 40,
|
||||||
|
fatG: 32,
|
||||||
|
netCarbG: 4,
|
||||||
|
phases: [1, 2, 3],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Zuppa di cavolfiore e gorgonzola',
|
||||||
|
category: 'CENA',
|
||||||
|
description: 'Crema di cavolfiore con gorgonzola dolce DOP.',
|
||||||
|
prepMinutes: 25,
|
||||||
|
difficulty: 'FACILE',
|
||||||
|
kcal: 420,
|
||||||
|
proteinG: 18,
|
||||||
|
fatG: 33,
|
||||||
|
netCarbG: 9,
|
||||||
|
phases: [1, 2, 3],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Tagliata di manzo con rucola e grana',
|
||||||
|
category: 'CENA',
|
||||||
|
description: 'Tagliata di manzo su letto di rucola con scaglie di grana.',
|
||||||
|
prepMinutes: 15,
|
||||||
|
difficulty: 'MEDIA',
|
||||||
|
kcal: 540,
|
||||||
|
proteinG: 44,
|
||||||
|
fatG: 36,
|
||||||
|
netCarbG: 3,
|
||||||
|
phases: [1, 2, 3],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -1,12 +1,21 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
import { SEED_RECIPES } from './seed-recipes.js';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
// TODO: dati di seed (utenti demo, ricette base, ecc.) — verranno aggiunti
|
// Idempotente: createMany con skipDuplicates su nome (unique implicit nope —
|
||||||
// quando le entità relative saranno modellate.
|
// usiamo upsert per ognuna).
|
||||||
|
let inserted = 0;
|
||||||
|
for (const r of SEED_RECIPES) {
|
||||||
|
const existing = await prisma.recipe.findFirst({ where: { name: r.name } });
|
||||||
|
if (existing) continue;
|
||||||
|
await prisma.recipe.create({ data: r });
|
||||||
|
inserted++;
|
||||||
|
}
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.info('[seed] nothing to seed yet');
|
console.info(`[seed] recipes inserted: ${inserted} (catalog total ${SEED_RECIPES.length})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
export * from './nutrition/bmr.js';
|
export * from './nutrition/bmr.js';
|
||||||
export * from './nutrition/tdee.js';
|
export * from './nutrition/tdee.js';
|
||||||
export * from './nutrition/types.js';
|
export * from './nutrition/types.js';
|
||||||
|
export * from './planner/macros.js';
|
||||||
|
export * from './planner/matchmaking.js';
|
||||||
|
export * from './planner/types.js';
|
||||||
export * from './profile/schema.js';
|
export * from './profile/schema.js';
|
||||||
|
export * from './tracking/schema.js';
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { type Phase } from './types.js';
|
||||||
|
|
||||||
|
// PRD §9.3 — distribuzione macro per fase.
|
||||||
|
// Ritorna i target giornalieri (kcal/proteine/grassi/carboidrati netti) date
|
||||||
|
// le calorie totali del giorno e la fase.
|
||||||
|
export interface DailyMacros {
|
||||||
|
kcal: number;
|
||||||
|
proteinG: number;
|
||||||
|
fatG: number;
|
||||||
|
netCarbG: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PhaseMacroInput {
|
||||||
|
kcalTarget: number;
|
||||||
|
weightKg: number;
|
||||||
|
phase: Phase;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KCAL_PER_GRAM = { protein: 4, fat: 9, netCarb: 4 } as const;
|
||||||
|
|
||||||
|
export function macrosForPhase({ kcalTarget, weightKg, phase }: PhaseMacroInput): DailyMacros {
|
||||||
|
// Proteine: 1.6 g/kg di peso ideale, leggermente più alte in fase 1.
|
||||||
|
const proteinPerKg = phase === 1 ? 1.8 : 1.6;
|
||||||
|
const proteinG = Math.round(weightKg * proteinPerKg);
|
||||||
|
|
||||||
|
// Carboidrati netti per fase (PRD §9.3).
|
||||||
|
const netCarbG = phase === 1 ? 25 : phase === 2 ? 60 : 120;
|
||||||
|
|
||||||
|
// Grassi: il resto.
|
||||||
|
const proteinKcal = proteinG * KCAL_PER_GRAM.protein;
|
||||||
|
const carbKcal = netCarbG * KCAL_PER_GRAM.netCarb;
|
||||||
|
const fatKcal = Math.max(0, kcalTarget - proteinKcal - carbKcal);
|
||||||
|
const fatG = Math.round(fatKcal / KCAL_PER_GRAM.fat);
|
||||||
|
|
||||||
|
return { kcal: kcalTarget, proteinG, fatG, netCarbG };
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { DEFAULT_MEAL_SHARE, matchMeals, type RecipeCandidate } from './matchmaking.js';
|
||||||
|
|
||||||
|
const recipes: RecipeCandidate[] = [
|
||||||
|
{
|
||||||
|
id: 'r1',
|
||||||
|
name: 'Frittata di spinaci',
|
||||||
|
category: 'COLAZIONE',
|
||||||
|
kcal: 350,
|
||||||
|
proteinG: 25,
|
||||||
|
fatG: 25,
|
||||||
|
netCarbG: 4,
|
||||||
|
exclusionTags: ['eggs', 'lactose'],
|
||||||
|
phases: [1, 2, 3],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'r2',
|
||||||
|
name: 'Avocado toast keto',
|
||||||
|
category: 'COLAZIONE',
|
||||||
|
kcal: 380,
|
||||||
|
proteinG: 12,
|
||||||
|
fatG: 30,
|
||||||
|
netCarbG: 6,
|
||||||
|
exclusionTags: [],
|
||||||
|
phases: [1, 2, 3],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'r3',
|
||||||
|
name: 'Pasta integrale al pomodoro',
|
||||||
|
category: 'COLAZIONE',
|
||||||
|
kcal: 420,
|
||||||
|
proteinG: 14,
|
||||||
|
fatG: 8,
|
||||||
|
netCarbG: 60,
|
||||||
|
exclusionTags: ['gluten'],
|
||||||
|
phases: [3], // ammessa solo in mantenimento
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('matchMeals', () => {
|
||||||
|
it('filters by excluded tags', () => {
|
||||||
|
const out = matchMeals({
|
||||||
|
candidates: recipes,
|
||||||
|
meal: 'COLAZIONE',
|
||||||
|
phase: 1,
|
||||||
|
excludedTags: ['eggs'],
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by phase compatibility', () => {
|
||||||
|
const out = matchMeals({
|
||||||
|
candidates: recipes,
|
||||||
|
meal: 'COLAZIONE',
|
||||||
|
phase: 1,
|
||||||
|
excludedTags: [],
|
||||||
|
dailyTarget: { kcal: 1900, proteinG: 130, fatG: 130, netCarbG: 25 },
|
||||||
|
mealShare: DEFAULT_MEAL_SHARE,
|
||||||
|
});
|
||||||
|
expect(out.find((r) => r.id === 'r3')).toBeUndefined(); // r3 è solo fase 3
|
||||||
|
});
|
||||||
|
|
||||||
|
it('penalises recently consumed recipes', () => {
|
||||||
|
const without = matchMeals({
|
||||||
|
candidates: recipes,
|
||||||
|
meal: 'COLAZIONE',
|
||||||
|
phase: 1,
|
||||||
|
excludedTags: [],
|
||||||
|
dailyTarget: { kcal: 1900, proteinG: 130, fatG: 130, netCarbG: 25 },
|
||||||
|
mealShare: DEFAULT_MEAL_SHARE,
|
||||||
|
});
|
||||||
|
const winner = without[0]!.id;
|
||||||
|
const withRecent = matchMeals({
|
||||||
|
candidates: recipes,
|
||||||
|
meal: 'COLAZIONE',
|
||||||
|
phase: 1,
|
||||||
|
excludedTags: [],
|
||||||
|
recentlyConsumedIds: [winner],
|
||||||
|
dailyTarget: { kcal: 1900, proteinG: 130, fatG: 130, netCarbG: 25 },
|
||||||
|
mealShare: DEFAULT_MEAL_SHARE,
|
||||||
|
});
|
||||||
|
expect(withRecent[0]!.id).not.toBe(winner);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects topN', () => {
|
||||||
|
const out = matchMeals({
|
||||||
|
candidates: recipes,
|
||||||
|
meal: 'COLAZIONE',
|
||||||
|
phase: 1,
|
||||||
|
excludedTags: [],
|
||||||
|
dailyTarget: { kcal: 1900, proteinG: 130, fatG: 130, netCarbG: 25 },
|
||||||
|
mealShare: DEFAULT_MEAL_SHARE,
|
||||||
|
topN: 1,
|
||||||
|
});
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores recipes whose category does not match the meal', () => {
|
||||||
|
const out = matchMeals({
|
||||||
|
candidates: recipes,
|
||||||
|
meal: 'CENA',
|
||||||
|
phase: 1,
|
||||||
|
excludedTags: [],
|
||||||
|
dailyTarget: { kcal: 1900, proteinG: 130, fatG: 130, netCarbG: 25 },
|
||||||
|
mealShare: DEFAULT_MEAL_SHARE,
|
||||||
|
});
|
||||||
|
expect(out).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// PRD §9.4 — algoritmo di matchmaking pasti.
|
||||||
|
// Implementazione pura senza dipendenze: filtra ricette per esclusioni, fase
|
||||||
|
// e categoria pasto; calcola un punteggio di distanza dai macros target;
|
||||||
|
// penalizza ricette consumate di recente; bilancia sui macros già accumulati.
|
||||||
|
// Non importa nulla da @ketopath/db perché l'algoritmo deve essere
|
||||||
|
// utilizzabile anche con fixture in test e con feature-flag indipendenti
|
||||||
|
// dalla persistenza.
|
||||||
|
|
||||||
|
export interface RecipeCandidate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: 'COLAZIONE' | 'PRANZO' | 'SPUNTINO' | 'CENA';
|
||||||
|
kcal: number;
|
||||||
|
proteinG: number;
|
||||||
|
fatG: number;
|
||||||
|
netCarbG: number;
|
||||||
|
exclusionTags: ReadonlyArray<string>;
|
||||||
|
phases: ReadonlyArray<1 | 2 | 3>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MacroTargets {
|
||||||
|
kcal: number;
|
||||||
|
proteinG: number;
|
||||||
|
fatG: number;
|
||||||
|
netCarbG: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchOptions {
|
||||||
|
candidates: ReadonlyArray<RecipeCandidate>;
|
||||||
|
meal: RecipeCandidate['category'];
|
||||||
|
phase: 1 | 2 | 3;
|
||||||
|
excludedTags: 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).
|
||||||
|
consumedSoFar?: MacroTargets;
|
||||||
|
dailyTarget: MacroTargets;
|
||||||
|
// Quote del pasto sul totale giornaliero (sommano a 1.0).
|
||||||
|
mealShare: { COLAZIONE: number; PRANZO: number; SPUNTINO: number; CENA: number };
|
||||||
|
topN?: number; // default 5
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchResult extends RecipeCandidate {
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restituisce le `topN` ricette più adatte, ordinate dalla migliore
|
||||||
|
* (score più basso) alla peggiore. Lo score combina:
|
||||||
|
* - distanza euclidea dai macros target del pasto (pesato sul giorno residuo)
|
||||||
|
* - penalità additiva se la ricetta è stata consumata negli ultimi N giorni
|
||||||
|
*/
|
||||||
|
export function matchMeals(opts: MatchOptions): MatchResult[] {
|
||||||
|
const consumed = opts.consumedSoFar ?? { kcal: 0, proteinG: 0, fatG: 0, netCarbG: 0 };
|
||||||
|
const remainingTarget: MacroTargets = {
|
||||||
|
kcal: Math.max(0, opts.dailyTarget.kcal - consumed.kcal),
|
||||||
|
proteinG: Math.max(0, opts.dailyTarget.proteinG - consumed.proteinG),
|
||||||
|
fatG: Math.max(0, opts.dailyTarget.fatG - consumed.fatG),
|
||||||
|
netCarbG: Math.max(0, opts.dailyTarget.netCarbG - consumed.netCarbG),
|
||||||
|
};
|
||||||
|
const share = opts.mealShare[opts.meal];
|
||||||
|
const mealTarget: MacroTargets = {
|
||||||
|
kcal: remainingTarget.kcal * share,
|
||||||
|
proteinG: remainingTarget.proteinG * share,
|
||||||
|
fatG: remainingTarget.fatG * share,
|
||||||
|
netCarbG: remainingTarget.netCarbG * share,
|
||||||
|
};
|
||||||
|
|
||||||
|
const recentSet = new Set(opts.recentlyConsumedIds ?? []);
|
||||||
|
const exclusionSet = new Set(opts.excludedTags);
|
||||||
|
|
||||||
|
const results: MatchResult[] = [];
|
||||||
|
|
||||||
|
for (const r of opts.candidates) {
|
||||||
|
if (r.category !== opts.meal) continue;
|
||||||
|
if (!r.phases.includes(opts.phase)) continue;
|
||||||
|
if (r.exclusionTags.some((tag) => exclusionSet.has(tag))) continue;
|
||||||
|
|
||||||
|
const dKcal = (r.kcal - mealTarget.kcal) / Math.max(1, mealTarget.kcal);
|
||||||
|
const dPro = (r.proteinG - mealTarget.proteinG) / Math.max(1, mealTarget.proteinG);
|
||||||
|
const dFat = (r.fatG - mealTarget.fatG) / Math.max(1, mealTarget.fatG);
|
||||||
|
const dCarb = (r.netCarbG - mealTarget.netCarbG) / Math.max(1, mealTarget.netCarbG);
|
||||||
|
|
||||||
|
let score = Math.sqrt(dKcal * dKcal + dPro * dPro + dFat * dFat + dCarb * dCarb);
|
||||||
|
if (recentSet.has(r.id)) score += 1.5;
|
||||||
|
|
||||||
|
results.push({ ...r, score });
|
||||||
|
}
|
||||||
|
|
||||||
|
results.sort((a, b) => a.score - b.score);
|
||||||
|
return results.slice(0, opts.topN ?? 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default share per pasto (somma 1.0). Può essere sovrascritta dall'utente.
|
||||||
|
export const DEFAULT_MEAL_SHARE = {
|
||||||
|
COLAZIONE: 0.25,
|
||||||
|
PRANZO: 0.35,
|
||||||
|
SPUNTINO: 0.1,
|
||||||
|
CENA: 0.3,
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type Phase = 1 | 2 | 3;
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const FASTING_PROTOCOLS = [
|
||||||
|
'FOURTEEN_TEN',
|
||||||
|
'SIXTEEN_EIGHT',
|
||||||
|
'EIGHTEEN_SIX',
|
||||||
|
'TWENTY_FOUR',
|
||||||
|
'ESE_24',
|
||||||
|
'FIVE_TWO',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const FAST_STATUSES = ['IN_PROGRESS', 'COMPLETED', 'ABORTED'] as const;
|
||||||
|
|
||||||
|
const measurementsSchema = z
|
||||||
|
.object({
|
||||||
|
waistCm: z.coerce.number().min(40).max(200).optional(),
|
||||||
|
hipsCm: z.coerce.number().min(40).max(200).optional(),
|
||||||
|
thighCm: z.coerce.number().min(20).max(120).optional(),
|
||||||
|
armCm: z.coerce.number().min(15).max(80).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const weightEntryInputSchema = z.object({
|
||||||
|
date: z.coerce.date(),
|
||||||
|
weightKg: z.coerce.number().min(35).max(300),
|
||||||
|
measurements: measurementsSchema.optional(),
|
||||||
|
notes: z.string().max(1000).optional(),
|
||||||
|
energy: z.coerce.number().int().min(1).max(10).optional(),
|
||||||
|
sleep: z.coerce.number().int().min(1).max(10).optional(),
|
||||||
|
hunger: z.coerce.number().int().min(1).max(10).optional(),
|
||||||
|
photos: z.array(z.string().url()).max(3).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WeightEntryInput = z.input<typeof weightEntryInputSchema>;
|
||||||
|
|
||||||
|
const symptomsSchema = z
|
||||||
|
.object({
|
||||||
|
headache: z.boolean().optional(),
|
||||||
|
energy: z.coerce.number().int().min(1).max(10).optional(),
|
||||||
|
hunger: z.coerce.number().int().min(1).max(10).optional(),
|
||||||
|
clarity: z.coerce.number().int().min(1).max(10).optional(),
|
||||||
|
other: z.string().max(500).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const fastEventStartSchema = z.object({
|
||||||
|
protocol: z.enum(FASTING_PROTOCOLS),
|
||||||
|
startedAt: z.coerce.date().optional(),
|
||||||
|
targetDuration: z.coerce.number().int().positive().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fastEventUpdateSchema = z.object({
|
||||||
|
endedAt: z.coerce.date().optional(),
|
||||||
|
status: z.enum(FAST_STATUSES).optional(),
|
||||||
|
symptoms: symptomsSchema.optional(),
|
||||||
|
notes: z.string().max(1000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FastEventStartInput = z.input<typeof fastEventStartSchema>;
|
||||||
|
export type FastEventUpdateInput = z.input<typeof fastEventUpdateSchema>;
|
||||||
|
|
||||||
|
// Default targetDuration in minutes per protocol.
|
||||||
|
export const PROTOCOL_DEFAULT_MINUTES: Record<(typeof FASTING_PROTOCOLS)[number], number> = {
|
||||||
|
FOURTEEN_TEN: 14 * 60,
|
||||||
|
SIXTEEN_EIGHT: 16 * 60,
|
||||||
|
EIGHTEEN_SIX: 18 * 60,
|
||||||
|
TWENTY_FOUR: 20 * 60,
|
||||||
|
ESE_24: 24 * 60,
|
||||||
|
FIVE_TWO: 24 * 60,
|
||||||
|
};
|
||||||
Reference in new issue
Block a user