aboutsummaryrefslogtreecommitdiff
path: root/src/lib/shared
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-09-08 13:44:59 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-09-08 13:44:59 +0200
commit212c3adc8a584e6517a2816fc6683688d5ac5d7a (patch)
tree59cccb6155a739376fce05faa833076ac4b52ad4 /src/lib/shared
parente1d80af9899e078f53d2232d2d7ef4680a14bf1c (diff)
Project health chart on LayerChart
Project page gets a Sante chart: daily mean of per-routine health via computeProjectHealthSeries, ending at the current health score. Both charts share a HealthChart component built on LayerChart's AreaChart (time x-axis, fixed 0-100 y-axis, completion dots).
Diffstat (limited to 'src/lib/shared')
-rw-r--r--src/lib/shared/routine-metrics.test.ts49
-rw-r--r--src/lib/shared/routine-metrics.ts72
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';