aboutsummaryrefslogtreecommitdiff
path: root/src/lib/shared/routine-metrics.ts
blob: 2ac245e10cbf770a91cb478687412c4165d940ae (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

// Pure routine metrics, shared by server services and UI. No db access.
// All timestamps are ISO strings; all math is done in ms then converted to days.

const DAY_MS = 24 * 60 * 60 * 1000;

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, 0 when overdue by a full frequency, floor at 0. */
	health: number;
}

export function computeRoutineState(
	routine: RoutineInput,
	completions: CompletionInput[],
	now: number,
): RoutineState {
	const mine = completions
		.filter((c) => c.routineId === routine.id)
		.map((c) => Date.parse(c.doneAt));
	const lastDoneMs = mine.length > 0 ? Math.max(...mine) : null;
	const createdMs = Date.parse(routine.createdAt);
	const referenceMs =
		lastDoneMs ?? (Number.isFinite(createdMs) ? createdMs : now);
	const periodMs = routine.frequencyDays * DAY_MS;
	const dueMs = referenceMs + periodMs;
	const rawRatio = (now - dueMs) / periodMs;
	const overdueRatio = Math.max(0, rawRatio);
	// Buckets on overdue time in units of the routine's own frequency:
	// not due / due now / late (half a period overdue) / very late (a full
	// period overdue — a weekly routine two weeks old lands here).
	const status: RoutineStatus =
		rawRatio < 0
			? 'ok'
			: overdueRatio < 0.5
				? 'due'
				: overdueRatio < 1
					? 'late'
					: 'veryLate';

	return {
		id: routine.id,
		version: routine.version,
		name: routine.name,
		frequencyDays: routine.frequencyDays,
		lastDoneAt: lastDoneMs !== null ? new Date(lastDoneMs).toISOString() : null,
		dueAt: new Date(dueMs).toISOString(),
		overdueDays: Math.floor(overdueRatio * routine.frequencyDays),
		overdueRatio,
		status,
		health: Math.max(0, Math.round(100 - overdueRatio * 100)),
	};
}

/** 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 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',
};