aboutsummaryrefslogtreecommitdiff
path: root/src/lib/shared
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/shared')
-rw-r--r--src/lib/shared/routine-metrics.test.ts46
-rw-r--r--src/lib/shared/routine-metrics.ts59
2 files changed, 105 insertions, 0 deletions
diff --git a/src/lib/shared/routine-metrics.test.ts b/src/lib/shared/routine-metrics.test.ts
index 4a84eda..a63069c 100644
--- a/src/lib/shared/routine-metrics.test.ts
+++ b/src/lib/shared/routine-metrics.test.ts
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import {
computeRoutineState,
computeHealthScore,
+ computeHealthSeries,
parseIntervalToDays,
} from './routine-metrics';
@@ -64,6 +65,51 @@ describe('computeRoutineState', () => {
});
});
+describe('computeHealthSeries', () => {
+ it('decays from 100 at creation to 0 two periods late when never done', () => {
+ const now = Date.parse(CREATED) + 15 * DAY_MS;
+ const series = computeHealthSeries(routine, [], now);
+ expect(series[0].health).toBe(100);
+ const at8d = series.find((p) => p.at === Date.parse(CREATED) + 8 * DAY_MS);
+ expect(at8d?.health).toBe(86); // 1/7 of a period overdue
+ const last = series[series.length - 1];
+ expect(last.at).toBe(now);
+ expect(last.health).toBe(0); // more than a full period overdue
+ });
+
+ it('resets to 100 right after a completion, then decays again (sawtooth)', () => {
+ const doneAt = Date.parse(CREATED) + 5 * DAY_MS;
+ const now = doneAt + 13 * DAY_MS; // 13 days after the completion
+ const series = computeHealthSeries(
+ routine,
+ [{ routineId: 1, doneAt: new Date(doneAt).toISOString() }],
+ now,
+ );
+ const rightAfter = series.find((p) => p.at >= doneAt);
+ expect(rightAfter?.health).toBe(100);
+ // 8 days after the completion: 1 day into the 7-day period overdue.
+ const at8d = series.find((p) => p.at === doneAt + 8 * DAY_MS);
+ expect(at8d?.health).toBe(86);
+ });
+
+ it('a second completion resets the decay again', () => {
+ const first = Date.parse(CREATED) + 3 * DAY_MS;
+ const second = Date.parse(CREATED) + 10 * DAY_MS;
+ const now = Date.parse(CREATED) + 11 * DAY_MS;
+ const series = computeHealthSeries(
+ routine,
+ [
+ { routineId: 1, doneAt: new Date(first).toISOString() },
+ { routineId: 1, doneAt: new Date(second).toISOString() },
+ ],
+ now,
+ );
+ // Just after the second completion the routine is back at full health.
+ const nearEnd = series[series.length - 1];
+ expect(nearEnd.health).toBe(100);
+ });
+});
+
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 2ac245e..99e5c56 100644
--- a/src/lib/shared/routine-metrics.ts
+++ b/src/lib/shared/routine-metrics.ts
@@ -84,6 +84,65 @@ export function computeHealthScore(states: { health: number }[]): number {
return Math.round(total / states.length);
}
+export interface HealthPoint {
+ /** Epoch ms of the sample. */
+ at: number;
+ /** Health (0-100) at that instant. */
+ health: number;
+}
+
+/**
+ * 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).
+ * Each sample anchors on the latest completion at or before it (or creation).
+ * Uses the routine's current frequency across the whole history.
+ */
+export function computeHealthSeries(
+ routine: RoutineInput,
+ completions: CompletionInput[],
+ now: number,
+): HealthPoint[] {
+ const createdMs = Date.parse(routine.createdAt);
+ const startMs = Number.isFinite(createdMs) ? createdMs : now;
+ const periodMs = routine.frequencyDays * DAY_MS;
+
+ const completionMs = completions
+ .filter((c) => c.routineId === routine.id)
+ .map((c) => Date.parse(c.doneAt))
+ .sort((a, b) => a - b);
+
+ const points: HealthPoint[] = [];
+ if (now <= startMs) {
+ return [{ at: now, health: 100 }];
+ }
+
+ // Reference anchor: latest completion at-or-before the sampled instant.
+ let refMs = startMs;
+ let nextCompletion = 0;
+ const sampleAt = (t: number): number => {
+ while (
+ nextCompletion < completionMs.length &&
+ completionMs[nextCompletion] <= t
+ ) {
+ refMs = completionMs[nextCompletion];
+ nextCompletion++;
+ }
+ const dueMs = refMs + periodMs;
+ const rawRatio = (t - dueMs) / periodMs;
+ return Math.max(0, Math.round(100 - Math.max(0, rawRatio) * 100));
+ };
+
+ for (let t = startMs; t <= now; t += DAY_MS) {
+ points.push({ at: t, health: sampleAt(t) });
+ }
+
+ // 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) });
+ }
+ return points;
+}
+
export function formatFrequency(days: number): string {
if (days === 1) return 'chaque jour';
if (days === 7) return 'chaque semaine';