feat: §5.3 completion — stats, symptoms, milestones, pause, break-fast

Cinque feature che chiudono il PRD §5.3 (gestione digiuno).

§5.3.1 Streak + statistiche settimanali/mensili
- @ketopath/shared/tracking/fasting-stats: computeFastingStats(events) ritorna
  weeklyHours, monthlyHours, currentStreak, longestStreak, completionRate,
  totalSessions, completedSessions
- 6 unit test (currentStreak consecutivo, longestStreak storico, completionRate
  ignora IN_PROGRESS, range temporali)
- /fasting: la striscia di stat passa da 3 a 5 (settimanali, mensili, streak
  attuale, streak record, % completamento)

§5.3.2 Diario sintomi durante il digiuno
- updateFastSymptoms server action → PATCH /me/fast-events/:id { symptoms }
- SymptomsDiary component nel timer attivo: checkbox "Mal di testa" + 3 slider
  1-10 (Energia, Fame, Lucidità) + textarea note libere; "Salva" non
  interrompe il timer

§5.3.3 Notifiche per-fast
- FastEvent.notifiedMilestones String[] (idempotenza)
- Cron */5 * * * * `runFastingMilestonesJob` scansiona le sessioni IN_PROGRESS
  e invia push secondo le milestone:
  · hydration_2h / 4h / 6h — promemoria idratazione
  · electrolyte_18h — alert elettroliti per digiuni lunghi
  · ending_soon — 30 min prima della fine
- Skip automatico se l'utente ha fastingPausedUntil futuro
- Skip se l'utente non ha attivato `fastingMilestones` nelle preferenze
- Server.ts gestisce ora 2 ScheduledTask (weekly + fasting)

§5.3.4 Modalità giorno libero
- User.fastingPausedUntil DateTime?
- POST /me/fasting/pause con body { until?: ISO|null } — default: domani 06:00
- GET /me/fasting/pause ritorna { fastingPausedUntil, paused }
- /fasting: FastingPauseToggle bottone "Pausa per oggi" → banner con countdown +
  "Riprendi adesso" per terminare prima

§5.3.5 Pasto di rottura intelligente
- GET /me/fast-events/:id/break-fast-suggestions: solo per digiuni >= 24h,
  ritorna 3 ricette con prepMinutes ≤ 20, kcal ≤ 350, categoria
  COLAZIONE/SPUNTINO. Heuristica per "facile da digerire".
- /fasting history: per le sessioni completate >= 24h aggiunge un disclosure
  "Suggerimenti per riprendere a mangiare" che fetcha on-demand e linka alle
  ricette

Schema migrations: add_fasting_paused_until, add_fast_event_milestones.

72/72 unit test verdi (6 nuovi). Lint, typecheck, build 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:41:20 +02:00
1 parent c37dc5d717
commit ea88ed8775
16 files changed
+810 -54

No files matched your search

+1
View File
@@ -13,4 +13,5 @@ export * from './planner/types.js';
export * from './preferences/training.js';
export * from './profile/diet-history.js';
export * from './profile/schema.js';
export * from './tracking/fasting-stats.js';
export * from './tracking/schema.js';
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { computeFastingStats, type FastingEventLike } from './fasting-stats.js';
const HOUR_MS = 3_600_000;
const DAY_MS = 24 * HOUR_MS;
function ev(
daysAgo: number,
hours: number,
status: FastingEventLike['status'] = 'COMPLETED',
): FastingEventLike {
const now = new Date('2026-04-29T12:00:00Z');
const start = new Date(now.getTime() - daysAgo * DAY_MS);
return {
status,
startedAt: start.toISOString(),
endedAt:
status === 'IN_PROGRESS' ? null : new Date(start.getTime() + hours * HOUR_MS).toISOString(),
targetDuration: hours * 60,
};
}
const NOW = new Date('2026-04-29T12:00:00Z');
describe('computeFastingStats', () => {
it('vuoto → tutti zeri', () => {
const s = computeFastingStats([], NOW);
expect(s).toEqual({
weeklyHours: 0,
monthlyHours: 0,
currentStreak: 0,
longestStreak: 0,
completionRate: 0,
totalSessions: 0,
completedSessions: 0,
});
});
it('weeklyHours somma solo sessioni completate negli ultimi 7 giorni', () => {
const s = computeFastingStats([ev(0, 16), ev(2, 18), ev(10, 24)], NOW);
expect(s.weeklyHours).toBeCloseTo(34, 5);
expect(s.monthlyHours).toBeCloseTo(58, 5);
});
it('currentStreak: 3 giorni consecutivi con sessione completed', () => {
const s = computeFastingStats([ev(0, 16), ev(1, 16), ev(2, 16), ev(4, 16)], NOW);
expect(s.currentStreak).toBe(3);
});
it('currentStreak salta se manca un giorno', () => {
const s = computeFastingStats([ev(0, 16), ev(2, 16)], NOW);
expect(s.currentStreak).toBe(1);
});
it('completionRate ignora sessioni in corso', () => {
const s = computeFastingStats(
[ev(0, 16, 'COMPLETED'), ev(1, 8, 'ABORTED'), ev(2, 0, 'IN_PROGRESS')],
NOW,
);
expect(s.completionRate).toBeCloseTo(0.5, 5);
});
it('longestStreak storico', () => {
const events = [ev(0, 16), ev(1, 16), ev(2, 16), ev(3, 16), ev(10, 16), ev(11, 16)];
const s = computeFastingStats(events, NOW);
expect(s.longestStreak).toBeGreaterThanOrEqual(4);
});
});
@@ -0,0 +1,94 @@
// PRD §5.3 — Statistiche del digiuno: streak, ore totali, completion rate.
//
// Funzione pura, riutilizzabile da UI e API. Lavora su array di eventi già
// caricati dal DB.
export interface FastingEventLike {
status: 'IN_PROGRESS' | 'COMPLETED' | 'ABORTED';
startedAt: Date | string;
endedAt: Date | string | null;
targetDuration: number; // minuti
}
export interface FastingStats {
weeklyHours: number; // ore di digiuno completato negli ultimi 7 giorni
monthlyHours: number; // ultimi 30 giorni
currentStreak: number; // giorni consecutivi con almeno una sessione COMPLETED
longestStreak: number; // record di sempre
completionRate: number; // [0, 1] su sessioni terminate (COMPLETED+ABORTED)
totalSessions: number;
completedSessions: number;
}
const DAY_MS = 24 * 60 * 60 * 1000;
function toDate(d: Date | string): Date {
return typeof d === 'string' ? new Date(d) : d;
}
function dayKey(d: Date): string {
return d.toISOString().slice(0, 10);
}
function durationMs(e: FastingEventLike): number {
const start = toDate(e.startedAt).getTime();
const end = e.endedAt ? toDate(e.endedAt).getTime() : start;
return Math.max(0, end - start);
}
export function computeFastingStats(
events: ReadonlyArray<FastingEventLike>,
now: Date = new Date(),
): FastingStats {
const completed = events.filter((e) => e.status === 'COMPLETED');
const finished = events.filter((e) => e.status !== 'IN_PROGRESS');
const oneWeekAgo = now.getTime() - 7 * DAY_MS;
const oneMonthAgo = now.getTime() - 30 * DAY_MS;
const weeklyMs = completed
.filter((e) => toDate(e.startedAt).getTime() >= oneWeekAgo)
.reduce((sum, e) => sum + durationMs(e), 0);
const monthlyMs = completed
.filter((e) => toDate(e.startedAt).getTime() >= oneMonthAgo)
.reduce((sum, e) => sum + durationMs(e), 0);
// Streak: contiamo giorni consecutivi (anchored a oggi) in cui esiste
// almeno una sessione COMPLETED.
const completedDays = new Set(completed.map((e) => dayKey(toDate(e.startedAt))));
let currentStreak = 0;
for (let offset = 0; offset < 365; offset++) {
const candidate = new Date(now.getTime() - offset * DAY_MS);
if (completedDays.has(dayKey(candidate))) {
currentStreak++;
} else {
break;
}
}
// Longest streak storico: scansiona i giorni completati ordinati e conta
// run consecutive.
const sortedDays = Array.from(completedDays).sort();
let longestStreak = 0;
let run = 0;
let prevTs: number | null = null;
for (const day of sortedDays) {
const ts = new Date(day + 'T00:00:00Z').getTime();
if (prevTs != null && ts - prevTs === DAY_MS) {
run++;
} else {
run = 1;
}
if (run > longestStreak) longestStreak = run;
prevTs = ts;
}
return {
weeklyHours: Number((weeklyMs / 3_600_000).toFixed(1)),
monthlyHours: Number((monthlyMs / 3_600_000).toFixed(1)),
currentStreak,
longestStreak,
completionRate: finished.length === 0 ? 0 : completed.length / finished.length,
totalSessions: events.length,
completedSessions: completed.length,
};
}