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:
1 parent
ea88ed8775
commit
9327e75395
18 files changed
+627
-8
No files matched your search
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "profiles" ADD COLUMN "alert_weight_kg" TEXT;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "daily_check_ins" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"energy" INTEGER,
|
||||
"sleep" INTEGER,
|
||||
"hunger" INTEGER,
|
||||
"mood" INTEGER,
|
||||
"notes" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "daily_check_ins_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "daily_check_ins_user_id_date_idx" ON "daily_check_ins"("user_id", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "daily_check_ins_user_id_date_key" ON "daily_check_ins"("user_id", "date");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "daily_check_ins" ADD CONSTRAINT "daily_check_ins_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -100,6 +100,7 @@ model User {
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
weightEntries WeightEntry[]
|
||||
dailyCheckIns DailyCheckIn[]
|
||||
fastEvents FastEvent[]
|
||||
mealPlans MealPlan[]
|
||||
deviceTokens DeviceToken[]
|
||||
@@ -187,6 +188,10 @@ model Profile {
|
||||
// PRD §5.1 — % grasso corporeo stimata (US Navy) per BMR Katch-McArdle.
|
||||
/// @encrypted
|
||||
bodyFatPct String? @map("body_fat_pct")
|
||||
// PRD §5.2 — soglia di allarme peso. Quando il peso corrente la supera,
|
||||
// mostriamo un banner di "settimana di riparazione" in /tracking.
|
||||
/// @encrypted
|
||||
alertWeightKg String? @map("alert_weight_kg")
|
||||
// PRD §5.1 — storia delle diete restrittive precedenti, per stimare
|
||||
// l'adattamento metabolico (Fothergill 2016, Rosenbaum 2008). Categoriale:
|
||||
// NONE / SOME / EXTENSIVE / SEVERE. Cifrato perché può alludere a
|
||||
@@ -228,6 +233,29 @@ model Preferences {
|
||||
|
||||
// PRD §5.2 — pesata settimanale, misure, indicatori soggettivi.
|
||||
// I campi sanitari sono cifrati at-rest (ADR 0002).
|
||||
// PRD §5.2 — check-in lampo quotidiano (10s): solo indicatori soggettivi.
|
||||
// Distinto da WeightEntry (settimanale, con peso e misure).
|
||||
model DailyCheckIn {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
date DateTime @db.Date
|
||||
energy Int? // 1..10
|
||||
sleep Int? // 1..10
|
||||
hunger Int? // 1..10
|
||||
mood Int? // 1..10
|
||||
/// @encrypted
|
||||
notes String?
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, date])
|
||||
@@index([userId, date])
|
||||
@@map("daily_check_ins")
|
||||
}
|
||||
|
||||
model WeightEntry {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
|
||||
@@ -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';
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user