diff options
Diffstat (limited to 'src/lib/shared')
| -rw-r--r-- | src/lib/shared/routine-metrics.test.ts | 49 | ||||
| -rw-r--r-- | src/lib/shared/routine-metrics.ts | 72 |
2 files changed, 118 insertions, 3 deletions
diff --git a/src/lib/shared/routine-metrics.test.ts b/src/lib/shared/routine-metrics.test.ts index ccc06ba..57b8c38 100644 --- a/src/lib/shared/routine-metrics.test.ts +++ b/src/lib/shared/routine-metrics.test.ts @@ -3,6 +3,7 @@ import { computeRoutineState, computeHealthScore, computeHealthSeries, + computeProjectHealthSeries, parseIntervalToDays, } from './routine-metrics'; @@ -125,6 +126,54 @@ describe('computeHealthSeries', () => { }); }); +describe('computeProjectHealthSeries', () => { + it('is empty with no routines', () => { + expect(computeProjectHealthSeries([], [], Date.parse(CREATED))).toEqual([]); + }); + + it('averages per-routine health and ends at the current score', () => { + const second = { ...routine, id: 2, name: 'read' }; + const doneAt = Date.parse(CREATED) + 5 * DAY_MS; + const now = Date.parse(CREATED) + 8 * DAY_MS; + const series = computeProjectHealthSeries( + [routine, second], + [{ routineId: 2, doneAt: new Date(doneAt).toISOString() }], + now, + ); + const states = [ + computeRoutineState(routine, [], now), + computeRoutineState( + second, + [{ routineId: 2, doneAt: new Date(doneAt).toISOString() }], + now, + ), + ]; + const last = series[series.length - 1]; + expect(last.at).toBe(now); + expect(last.health).toBe(computeHealthScore(states)); + }); + + it('only counts routines alive that day', () => { + const late = { + ...routine, + id: 2, + name: 'latecomer', + createdAt: new Date(Date.parse(CREATED) + 10 * DAY_MS).toISOString(), + }; + const now = Date.parse(CREATED) + 11 * DAY_MS; + const series = computeProjectHealthSeries([routine, late], [], now); + const beforeLate = series.find( + (p) => p.at === Date.parse(CREATED) + 5 * DAY_MS, + ); + const single = computeHealthSeries( + routine, + [], + Date.parse(CREATED) + 5 * DAY_MS, + ); + expect(beforeLate?.health).toBe(single[single.length - 1].health); + }); +}); + describe('parseIntervalToDays', () => { it('converts units to days', () => { expect(parseIntervalToDays('1d')).toBe(1); diff --git a/src/lib/shared/routine-metrics.ts b/src/lib/shared/routine-metrics.ts index 4af989d..891a0c9 100644 --- a/src/lib/shared/routine-metrics.ts +++ b/src/lib/shared/routine-metrics.ts @@ -103,6 +103,13 @@ export interface HealthPoint { health: number; } +/** Health at instant t given the reference anchor (creation or last completion). */ +function healthAtPoint(refMs: number, periodMs: number, t: number): number { + const dueMs = startOfUtcDay(refMs) + periodMs; + const rawRatio = (t - dueMs) / periodMs; + return Math.max(0, Math.round(100 - Math.max(0, rawRatio) * 100)); +} + /** * Daily health samples from the routine's creation to now: 100 right after a * completion, then a linear decay to 0 once a full period is overdue (sawtooth). @@ -139,9 +146,7 @@ export function computeHealthSeries( refMs = completionMs[nextCompletion]; nextCompletion++; } - const dueMs = startOfUtcDay(refMs) + periodMs; - const rawRatio = (t - dueMs) / periodMs; - return Math.max(0, Math.round(100 - Math.max(0, rawRatio) * 100)); + return healthAtPoint(refMs, periodMs, t); }; for (let t = startMs; t <= now; t += DAY_MS) { @@ -155,6 +160,67 @@ export function computeHealthSeries( return points; } +/** + * Project health series: daily mean of per-routine health across the routines + * alive that day (createdAt <= t). Same sawtooth as the per-routine series, + * so the project curve ends at the current health score. Empty when no + * routines. + */ +export function computeProjectHealthSeries( + routines: RoutineInput[], + completions: CompletionInput[], + now: number, +): HealthPoint[] { + if (routines.length === 0) return []; + const ids = new Set(routines.map((r) => r.id)); + const completionsByRoutine = new Map<number, number[]>(); + for (const c of completions) { + if (!ids.has(c.routineId)) continue; + const t = Date.parse(c.doneAt); + if (!Number.isFinite(t)) continue; + completionsByRoutine.set(c.routineId, [ + ...(completionsByRoutine.get(c.routineId) ?? []), + t, + ]); + } + for (const times of completionsByRoutine.values()) + times.sort((a, b) => a - b); + + const createdOrNow = (r: RoutineInput): number => { + const t = Date.parse(r.createdAt); + return Number.isFinite(t) ? t : now; + }; + // Mean per-routine health across the routines alive at t. + const sampleAt = (t: number): number | null => { + const alive = routines.filter((r) => Date.parse(r.createdAt) <= t); + if (alive.length === 0) return null; + const total = alive.reduce((sum, r) => { + const doneAt = (completionsByRoutine.get(r.id) ?? []).filter( + (d) => d <= t, + ); + const anchor = doneAt.length > 0 ? Math.max(...doneAt) : createdOrNow(r); + return sum + healthAtPoint(anchor, r.frequencyDays * DAY_MS, t); + }, 0); + return Math.round(total / alive.length); + }; + + const startMs = Math.min(...routines.map(createdOrNow)); + if (now <= startMs) return [{ at: now, health: 100 }]; + + const points: HealthPoint[] = []; + for (let t = startMs; t <= now; t += DAY_MS) { + const health = sampleAt(t); + if (health !== null) points.push({ at: t, health }); + } + if (points.length === 0) return [{ at: now, health: 100 }]; + + // Final unaligned sample so the curve ends at the present instant. + if (points[points.length - 1].at < now) { + points.push({ at: now, health: sampleAt(now) ?? 100 }); + } + return points; +} + export function formatFrequency(days: number): string { if (days === 1) return 'chaque jour'; if (days === 7) return 'chaque semaine'; |
