aboutsummaryrefslogtreecommitdiff
path: root/src/lib/shared/routine-metrics.ts
blob: 7758f270290ed9d21ce27bd5a9beceaac524ae1d (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319

// Pure routine metrics, shared by server services and UI. No db access.
// All timestamps are ISO strings; calendar math uses Temporal in UTC: due
// dates anchor on UTC calendar days, not exact 24h intervals: a daily
// routine done yesterday is due today, whatever the time of day.

const UTC = 'UTC';

/** Parse an ISO stamp, falling back when it is not a valid instant. */
function parseInstant(
	value: string,
	fallback: Temporal.Instant,
): Temporal.Instant {
	try {
		return Temporal.Instant.from(value);
	} catch {
		return fallback;
	}
}

/** Millisecond-precision ISO stamp, byte-identical to Date.toISOString(). */
function iso(instant: Temporal.Instant): string {
	return instant.toString({ smallestUnit: 'millisecond' });
}

/** Whole days as an exact millisecond count. */
function periodMs(days: number): number {
	return Temporal.Duration.from({ days }).total({ unit: 'millisecond' });
}

function maxInstant(instants: Temporal.Instant[]): Temporal.Instant | null {
	if (instants.length === 0) return null;
	return instants.reduce((a, b) =>
		Temporal.Instant.compare(a, b) >= 0 ? a : b,
	);
}

function parseCompletions(
	routineId: number,
	completions: CompletionInput[],
): Temporal.Instant[] {
	const mine: Temporal.Instant[] = [];
	for (const c of completions) {
		if (c.routineId !== routineId) continue;
		try {
			mine.push(Temporal.Instant.from(c.doneAt));
		} catch {
			// Skip corrupt stamps instead of poisoning the anchor.
		}
	}
	return mine.sort(Temporal.Instant.compare);
}

export type RoutineStatus = 'ok' | 'due' | 'late' | 'veryLate';

export interface RoutineInput {
	id: number;
	version: number;
	name: string;
	frequencyDays: number;
	createdAt: string;
}

export interface CompletionInput {
	routineId: number;
	doneAt: string;
}

export interface RoutineState {
	id: number;
	version: number;
	name: string;
	frequencyDays: number;
	lastDoneAt: string | null;
	/** When the routine should next be done. */
	dueAt: string;
	/** Days past dueAt (0 if not yet due). */
	overdueDays: number;
	/** Time past due, in units of the routine's own frequency (0 if not due). */
	overdueRatio: number;
	status: RoutineStatus;
	/** 100 on schedule, 50 when a full period overdue, 0 at two periods, floor at 0. */
	health: number;
}

export function computeRoutineState(
	routine: RoutineInput,
	completions: CompletionInput[],
	now: Temporal.Instant,
): RoutineState {
	const lastDone = maxInstant(parseCompletions(routine.id, completions));
	const reference = lastDone ?? parseInstant(routine.createdAt, now);
	const period = periodMs(routine.frequencyDays);
	// Calendar-day anchor: the period starts at midnight of the reference day,
	// so any new calendar day past the frequency flips the routine to due.
	// A never-done routine has no completed period: its creation day is its
	// due date, so it ages exactly like a done one whose anchor is creation.
	const due = reference
		.toZonedDateTimeISO(UTC)
		.startOfDay()
		.add({ days: lastDone === null ? 0 : routine.frequencyDays })
		.toInstant();
	const rawRatio = (now.epochMilliseconds - due.epochMilliseconds) / period;
	const overdueRatio = Math.max(0, rawRatio);
	const status: RoutineStatus =
		rawRatio < 0 && lastDone !== null
			? 'ok'
			: overdueRatio < 1
				? 'due'
				: overdueRatio < 2
					? 'late'
					: 'veryLate';

	return {
		id: routine.id,
		version: routine.version,
		name: routine.name,
		frequencyDays: routine.frequencyDays,
		lastDoneAt: lastDone !== null ? iso(lastDone) : null,
		dueAt: iso(due),
		overdueDays: Math.floor(overdueRatio * routine.frequencyDays),
		overdueRatio,
		status,
		health: Math.max(0, Math.round(100 - overdueRatio * 50)),
	};
}

/** Average health across routines; 100 when there is nothing to do. */
export function computeHealthScore(states: { health: number }[]): number {
	if (states.length === 0) return 100;
	const total = states.reduce((sum, s) => sum + s.health, 0);
	return Math.round(total / states.length);
}

export interface HealthPoint {
	/** Epoch ms of the sample. */
	at: number;
	/** Health (0-100) at that instant. */
	health: number;
}

/** Health at instant t given the reference anchor (creation or last completion). */
function healthAtPoint(
	reference: Temporal.Instant,
	frequencyDays: number,
	t: Temporal.Instant,
	neverDone = false,
): number {
	const due = reference
		.toZonedDateTimeISO(UTC)
		.startOfDay()
		.add({ days: neverDone ? 0 : frequencyDays })
		.toInstant();
	const rawRatio =
		(t.epochMilliseconds - due.epochMilliseconds) / periodMs(frequencyDays);
	return Math.max(0, Math.round(100 - Math.max(0, rawRatio) * 50));
}

/**
 * Daily health samples from the routine's creation to now: 100 right after a
 * completion, then a linear decay to 0 once two full periods are overdue
 * (sawtooth).
 * Each sample anchors on the latest completion at or before it (or creation,
 * which is then itself the due date for a never-done routine).
 * Uses the routine's current frequency across the whole history.
 */
export function computeHealthSeries(
	routine: RoutineInput,
	completions: CompletionInput[],
	now: Temporal.Instant,
): HealthPoint[] {
	const start = parseInstant(routine.createdAt, now);
	const doneAt = parseCompletions(routine.id, completions);

	const points: HealthPoint[] = [];
	if (Temporal.Instant.compare(now, start) <= 0) {
		return [{ at: now.epochMilliseconds, health: 100 }];
	}

	// Reference anchor: latest completion at-or-before the sampled instant.
	let reference = start;
	let nextCompletion = 0;
	const sampleAt = (t: Temporal.Instant): number => {
		while (
			nextCompletion < doneAt.length &&
			Temporal.Instant.compare(doneAt[nextCompletion], t) <= 0
		) {
			reference = doneAt[nextCompletion];
			nextCompletion++;
		}
		return healthAtPoint(
			reference,
			routine.frequencyDays,
			t,
			nextCompletion === 0,
		);
	};

	for (
		let t = start;
		Temporal.Instant.compare(t, now) <= 0;
		t = t.toZonedDateTimeISO(UTC).add({ days: 1 }).toInstant()
	) {
		points.push({ at: t.epochMilliseconds, health: sampleAt(t) });
	}

	// Final unaligned sample so the curve ends at the present instant.
	if (points[points.length - 1].at < now.epochMilliseconds) {
		points.push({ at: now.epochMilliseconds, health: sampleAt(now) });
	}
	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: Temporal.Instant,
): HealthPoint[] {
	if (routines.length === 0) return [];
	const ids = new Set(routines.map((r) => r.id));
	const completionsByRoutine = new Map<number, Temporal.Instant[]>();
	for (const c of completions) {
		if (!ids.has(c.routineId)) continue;
		try {
			const t = Temporal.Instant.from(c.doneAt);
			completionsByRoutine.set(c.routineId, [
				...(completionsByRoutine.get(c.routineId) ?? []),
				t,
			]);
		} catch {
			// Skip corrupt stamps instead of poisoning the anchor.
		}
	}
	for (const times of completionsByRoutine.values())
		times.sort(Temporal.Instant.compare);

	const createdOrNow = (r: RoutineInput): Temporal.Instant =>
		parseInstant(r.createdAt, now);
	// Mean per-routine health across the routines alive at t.
	const sampleAt = (t: Temporal.Instant): number | null => {
		const alive = routines.filter((r) => {
			try {
				return (
					Temporal.Instant.compare(Temporal.Instant.from(r.createdAt), t) <= 0
				);
			} catch {
				return false;
			}
		});
		if (alive.length === 0) return null;
		const total = alive.reduce((sum, r) => {
			const doneAt = (completionsByRoutine.get(r.id) ?? []).filter(
				(d) => Temporal.Instant.compare(d, t) <= 0,
			);
			const anchor =
				doneAt.length > 0 ? (maxInstant(doneAt) ?? now) : createdOrNow(r);
			return (
				sum + healthAtPoint(anchor, r.frequencyDays, t, doneAt.length === 0)
			);
		}, 0);
		return Math.round(total / alive.length);
	};

	const start = routines
		.map(createdOrNow)
		.reduce((a, b) => (Temporal.Instant.compare(a, b) <= 0 ? a : b));
	if (Temporal.Instant.compare(now, start) <= 0)
		return [{ at: now.epochMilliseconds, health: 100 }];

	const points: HealthPoint[] = [];
	for (
		let t = start;
		Temporal.Instant.compare(t, now) <= 0;
		t = t.toZonedDateTimeISO(UTC).add({ days: 1 }).toInstant()
	) {
		const health = sampleAt(t);
		if (health !== null) points.push({ at: t.epochMilliseconds, health });
	}
	if (points.length === 0) return [{ at: now.epochMilliseconds, health: 100 }];

	// Final unaligned sample so the curve ends at the present instant.
	if (points[points.length - 1].at < now.epochMilliseconds) {
		points.push({ at: now.epochMilliseconds, health: sampleAt(now) ?? 100 });
	}
	return points;
}

export function formatFrequency(days: number): string {
	if (days === 1) return 'chaque jour';
	if (days === 7) return 'chaque semaine';
	if (days === 30) return 'chaque mois';
	if (days === 365) return 'chaque année';
	return `tous les ${days} jours`;
}

const UNIT_DAYS: Record<string, number> = { d: 1, w: 7, m: 30, y: 365 };

/** Parses "1d | 2w | 3m | 1y" into days; returns null when invalid. */
export function parseIntervalToDays(input: string): number | null {
	const match = input
		.trim()
		.toLowerCase()
		.match(/^(\d+)([dwmy])$/);
	if (!match) return null;
	const days = Number(match[1]) * UNIT_DAYS[match[2]];
	return days >= 1 ? days : null;
}

export const STATUS_LABELS: Record<RoutineStatus, string> = {
	ok: 'À jour',
	due: 'À faire',
	late: 'En retard',
	veryLate: 'Très en retard',
};