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

+1
View File
@@ -15,3 +15,4 @@ export * from './profile/diet-history.js';
export * from './profile/schema.js';
export * from './tracking/fasting-stats.js';
export * from './tracking/schema.js';
export * from './tracking/weight-trend.js';
+3
View File
@@ -27,6 +27,9 @@ export const profileInputSchema = z.object({
hipsCm: z.coerce.number().min(40).max(200).optional(),
// Storia delle diete restrittive precedenti (vedi diet-history.ts).
dietHistory: z.enum(DIET_HISTORIES).optional(),
// PRD §5.2 — soglia di allarme peso (kg). Tipicamente posta 2-3 kg sopra
// il peso obiettivo per intervenire prima del recupero.
alertWeightKg: z.coerce.number().min(35).max(300).optional(),
});
export type ProfileInput = z.input<typeof profileInputSchema>;
+12
View File
@@ -20,6 +20,18 @@ const measurementsSchema = z
})
.strict();
// PRD §5.2 — check-in lampo quotidiano. Form 10-secondi: solo soggettivi.
export const dailyCheckInInputSchema = z.object({
date: z.coerce.date(),
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(),
mood: z.coerce.number().int().min(1).max(10).optional(),
notes: z.string().max(500).optional(),
});
export type DailyCheckInInput = z.input<typeof dailyCheckInInputSchema>;
export const weightEntryInputSchema = z.object({
date: z.coerce.date(),
weightKg: z.coerce.number().min(35).max(300),
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import { linearWeightTrend, projectGoalDate } from './weight-trend.js';
const WEEK = 7 * 24 * 60 * 60 * 1000;
describe('linearWeightTrend', () => {
it('vuoto → null', () => {
expect(linearWeightTrend([])).toBeNull();
});
it('1 punto → slope 0', () => {
const t = linearWeightTrend([{ date: new Date(), weightKg: 80 }]);
expect(t?.slopeKgPerWeek).toBe(0);
expect(t?.interceptKg).toBe(80);
});
it('serie discendente perfetta: -0.5 kg/settimana', () => {
const t0 = new Date('2026-01-01').getTime();
const points = [0, 1, 2, 3, 4].map((w) => ({
date: new Date(t0 + w * WEEK),
weightKg: 80 - 0.5 * w,
}));
const t = linearWeightTrend(points);
expect(t?.slopeKgPerWeek).toBeCloseTo(-0.5, 4);
expect(t?.interceptKg).toBeCloseTo(80, 4);
});
it('serie ascendente: slope positivo', () => {
const t0 = new Date('2026-01-01').getTime();
const points = [
{ date: new Date(t0), weightKg: 70 },
{ date: new Date(t0 + WEEK), weightKg: 70.3 },
{ date: new Date(t0 + 2 * WEEK), weightKg: 70.7 },
];
const t = linearWeightTrend(points);
expect(t?.slopeKgPerWeek).toBeGreaterThan(0);
});
});
describe('projectGoalDate', () => {
const now = new Date('2026-04-29T00:00:00Z');
it('slope 0 → null', () => {
expect(projectGoalDate({ currentKg: 80, goalKg: 75, slopeKgPerWeek: 0, now })).toBeNull();
});
it('trend va nella direzione giusta → data nel futuro', () => {
const date = projectGoalDate({
currentKg: 80,
goalKg: 75,
slopeKgPerWeek: -0.5,
now,
});
expect(date).not.toBeNull();
// 5 kg / 0.5 = 10 settimane
const expectedMs = now.getTime() + 10 * WEEK;
expect(Math.abs(date!.getTime() - expectedMs)).toBeLessThan(1000);
});
it('trend opposto → null (segno contrario)', () => {
expect(projectGoalDate({ currentKg: 80, goalKg: 75, slopeKgPerWeek: 0.3, now })).toBeNull();
});
it('oltre 52 settimane → null', () => {
expect(projectGoalDate({ currentKg: 80, goalKg: 75, slopeKgPerWeek: -0.05, now })).toBeNull();
});
it('currentKg == goalKg → now', () => {
const r = projectGoalDate({ currentKg: 75, goalKg: 75, slopeKgPerWeek: -0.5, now });
expect(r).toEqual(now);
});
});
@@ -0,0 +1,79 @@
// PRD §5.2 — Trend del peso e proiezione data obiettivo.
//
// Regressione lineare semplice (least squares) sui WeightEntry. Output:
// pendenza in kg/settimana e intercetta. Da lì calcoliamo la data
// proiettata al raggiungimento dell'obiettivo, sotto vincoli di sicurezza
// (slope di segno coerente, max 12 mesi di proiezione).
export interface WeightPoint {
date: Date | string;
weightKg: number;
}
export interface WeightTrend {
slopeKgPerWeek: number;
interceptKg: number;
/** Numero di punti utilizzati. */
n: number;
}
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
function toDate(d: Date | string): Date {
return typeof d === 'string' ? new Date(d) : d;
}
/**
* Regressione lineare con t in settimane dal primo punto. Servono almeno 2
* punti distinti — altrimenti slope=0 e intercept=peso unico.
*/
export function linearWeightTrend(points: ReadonlyArray<WeightPoint>): WeightTrend | null {
if (points.length === 0) return null;
const sorted = [...points].sort((a, b) => toDate(a.date).getTime() - toDate(b.date).getTime());
const first = sorted[0]!;
if (sorted.length === 1) {
return { slopeKgPerWeek: 0, interceptKg: first.weightKg, n: 1 };
}
const t0 = toDate(first.date).getTime();
const xs = sorted.map((p) => (toDate(p.date).getTime() - t0) / WEEK_MS);
const ys = sorted.map((p) => p.weightKg);
const n = xs.length;
const sumX = xs.reduce((a, b) => a + b, 0);
const sumY = ys.reduce((a, b) => a + b, 0);
const sumXY = xs.reduce((acc, x, i) => acc + x * (ys[i] ?? 0), 0);
const sumXX = xs.reduce((acc, x) => acc + x * x, 0);
const denom = n * sumXX - sumX * sumX;
if (denom === 0) {
return { slopeKgPerWeek: 0, interceptKg: sumY / n, n };
}
const slope = (n * sumXY - sumX * sumY) / denom;
const intercept = (sumY - slope * sumX) / n;
return { slopeKgPerWeek: slope, interceptKg: intercept, n };
}
/**
* Data proiettata al raggiungimento di `goalKg`. Ritorna null se:
* - slope è zero, o
* - slope ha segno opposto a quello necessario (es. perdere peso ma il trend cresce), o
* - la data risulta oltre 12 mesi (rumorosa).
*/
export function projectGoalDate(args: {
currentKg: number;
goalKg: number;
slopeKgPerWeek: number;
now?: Date;
}): Date | null {
const { currentKg, goalKg, slopeKgPerWeek, now = new Date() } = args;
if (slopeKgPerWeek === 0) return null;
const delta = goalKg - currentKg;
if (delta === 0) return now;
// Direzioni opposte → trend non sta andando verso l'obiettivo.
if (Math.sign(delta) !== Math.sign(slopeKgPerWeek)) return null;
const weeks = delta / slopeKgPerWeek;
if (weeks <= 0) return null;
const ms = weeks * WEEK_MS;
// Cap a 12 mesi di proiezione realistica (52 settimane).
if (weeks > 52) return null;
return new Date(now.getTime() + ms);
}