feat: §5.2 batch — trend lineare, soglia allarme, guide misure, daily check-in

Quattro feature del PRD §5.2 Tracking peso e progressi.

§5.2.1 Trend regressione lineare + proiezione
- @ketopath/shared/tracking/weight-trend: linearWeightTrend() (least squares),
  projectGoalDate() con cap a 52 settimane e check di direzione coerente
- 9 unit test (slope/intercept, segno, cap)
- /tracking: terza statistica "Trend settimanale" + frase
  "Al ritmo attuale raggiungerai 75.0 kg il 24 luglio 2026"

§5.2.2 Soglia di allarme peso personalizzabile
- Profile.alertWeightKg @encrypted
- profileInputSchema accetta il campo, profile.routes lo persiste
- ProfileForm: nuovo input "Soglia di allarme peso (kg)"
- /tracking: banner pomodoro "Sei sopra la soglia" con suggerimento
  "settimana di riparazione" senza colpa

§5.2.3 Linee guida "come misurare bene"
- Disclosure inline in WeightEntryForm con istruzioni tecniche per
  girovita, fianchi, coscia, braccio + tip generale (ora del giorno,
  metro morbido, stessa procedura)

§5.2.4 Daily check-in indicatori soggettivi
- Nuovo modello DailyCheckIn (date, energy, sleep, hunger, mood, notes)
  con unique(userId, date) e migration
- shared dailyCheckInInputSchema (zod)
- API: GET /me/check-ins (last 30), GET /me/check-ins/today,
  POST /me/check-ins (upsert sul giorno)
- /tracking: card lampo in cima con 4 slider 1-10 + Salva/Aggiorna,
  10 secondi di compilazione

i18n: namespace Tracking esteso (weeklyTrend, projection, alertEyebrow/Message/Hint,
measurementsHelpToggle + measurementGuides{}, checkIn*, mood). Profile esteso
(alertWeightKg + hint).

Schema migrations: add_alert_weight_kg, add_daily_check_in.

81/81 unit test verdi (9 nuovi). Lint, typecheck verdi.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.7 committed 2026-04-29 23:50:05 +02:00
1 parent ea88ed8775
commit 9327e75395
18 files changed
+627 -8

No files matched your search

@@ -0,0 +1,89 @@
// PRD §5.2 — check-in lampo quotidiano: indicatori soggettivi (energia,
// sonno, fame, umore) compilabili in 10 secondi, una volta al giorno.
import { dailyCheckInInputSchema } from '@ketopath/shared';
import type { FastifyPluginAsync } from 'fastify';
import { requireAuth } from '../../plugins/auth.js';
function todayDateOnly(): Date {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}
export const checkInRoutes: FastifyPluginAsync = async (fastify) => {
fastify.get('/me/check-ins', { preHandler: requireAuth() }, async (request) => {
const userId = request.user!.id;
const items = await fastify.prisma.dailyCheckIn.findMany({
where: { userId },
orderBy: { date: 'desc' },
take: 30,
});
return {
items: items.map((c) => ({
id: c.id,
date: c.date.toISOString().slice(0, 10),
energy: c.energy,
sleep: c.sleep,
hunger: c.hunger,
mood: c.mood,
notes: c.notes,
})),
};
});
// GET /me/check-ins/today: ritorna il check-in di oggi (se esiste).
fastify.get('/me/check-ins/today', { preHandler: requireAuth() }, async (request) => {
const userId = request.user!.id;
const date = todayDateOnly();
const item = await fastify.prisma.dailyCheckIn.findUnique({
where: { userId_date: { userId, date } },
});
if (!item) return { item: null };
return {
item: {
id: item.id,
date: item.date.toISOString().slice(0, 10),
energy: item.energy,
sleep: item.sleep,
hunger: item.hunger,
mood: item.mood,
notes: item.notes,
},
};
});
fastify.post('/me/check-ins', { preHandler: requireAuth() }, async (request, reply) => {
const parsed = dailyCheckInInputSchema.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 date = new Date(data.date);
date.setHours(0, 0, 0, 0);
const item = await fastify.prisma.dailyCheckIn.upsert({
where: { userId_date: { userId, date } },
create: {
userId,
date,
energy: data.energy ?? null,
sleep: data.sleep ?? null,
hunger: data.hunger ?? null,
mood: data.mood ?? null,
notes: data.notes ?? null,
},
update: {
energy: data.energy ?? null,
sleep: data.sleep ?? null,
hunger: data.hunger ?? null,
mood: data.mood ?? null,
notes: data.notes ?? null,
},
});
return reply.code(201).send({ item: { id: item.id } });
});
};