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:
1 parent
c37dc5d717
commit
ea88ed8775
16 files changed
+810
-54
No files matched your search
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "fasting_paused_until" TIMESTAMP(3);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "fast_events" ADD COLUMN "notified_milestones" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
@@ -89,6 +89,9 @@ model User {
|
||||
// PRD §5.1 — momento in cui l'utente è entrato nella fase 2 (TRANSITION).
|
||||
// Serve a calcolare la "settimana di fase" per la reintroduzione progressiva.
|
||||
phase2StartedAt DateTime? @map("phase2_started_at")
|
||||
// PRD §5.3 — modalità "giorno libero" del digiuno: niente reminder, niente
|
||||
// start automatico finché il timestamp non è passato. NULL = nessuna pausa.
|
||||
fastingPausedUntil DateTime? @map("fasting_paused_until")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -263,6 +266,9 @@ model FastEvent {
|
||||
symptoms String?
|
||||
/// @encrypted
|
||||
notes String?
|
||||
// PRD §5.3 — milestone già notificate per evitare duplicati. Array di chiavi
|
||||
// tipo "hydration_2h", "hydration_4h", "electrolyte_18h", "ending_soon".
|
||||
notifiedMilestones String[] @default([]) @map("notified_milestones")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user