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
|
// 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.
// 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 DAY_MS = 24 * 60 * 60 * 1000;
/** Midnight UTC starting the calendar day containing ms. */
function startOfUtcDay(ms: number): number {
const d = new Date(ms);
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
}
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: 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;
// 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.
const dueMs = startOfUtcDay(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: due
// until a full period is missed (a daily done yesterday is due today, not
// late), late once an occurrence was skipped entirely, very late after two.
// A routine that was never done is always at least due: there is
// something to do, even right after creation.
const status: RoutineStatus =
rawRatio < 0 && lastDoneMs !== null
? 'ok'
: overdueRatio < 1
? 'due'
: overdueRatio < 2
? '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 * 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(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) * 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).
* 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++;
}
return healthAtPoint(refMs, periodMs, t);
};
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;
}
/**
* 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';
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',
};
|