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