aboutsummaryrefslogtreecommitdiff
path: root/src/lib/shared/routine-metrics.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/shared/routine-metrics.ts')
-rw-r--r--src/lib/shared/routine-metrics.ts285
1 files changed, 191 insertions, 94 deletions
diff --git a/src/lib/shared/routine-metrics.ts b/src/lib/shared/routine-metrics.ts
index 4cff240..d3c1470 100644
--- a/src/lib/shared/routine-metrics.ts
+++ b/src/lib/shared/routine-metrics.ts
@@ -1,17 +1,24 @@
// Pure routine metrics shared by server and UI; ISO timestamps, UTC calendar.
+import { Frequency } from './frequency';
+
const UTC = 'UTC';
+/** Parse an ISO stamp, null when it is not a valid instant. */
+function rawInstant(value: string): Temporal.Instant | null {
+ try {
+ return Temporal.Instant.from(value);
+ } catch {
+ return null;
+ }
+}
+
/** 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;
- }
+ return rawInstant(value) ?? fallback;
}
/** Millisecond-precision ISO stamp, byte-identical to Date.toISOString(). */
@@ -38,11 +45,9 @@ function parseCompletions(
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.
- }
+ const t = rawInstant(c.doneAt);
+ // Skip corrupt stamps instead of poisoning the anchor.
+ if (t !== null) mine.push(t);
}
return mine.sort(Temporal.Instant.compare);
}
@@ -53,7 +58,8 @@ export interface RoutineInput {
id: number;
version: number;
name: string;
- frequencyDays: number;
+ /** Spec text ("7d" interval, "5/2w" quota); parsed at compute time. */
+ frequency: string;
createdAt: string;
}
@@ -66,13 +72,17 @@ export interface RoutineState {
id: number;
version: number;
name: string;
- frequencyDays: number;
+ /** Spec text, as stored. */
+ frequency: string;
lastDoneAt: string | null;
- /** When the routine should next be done. */
+ /** When the pending obligation must be done (window end for quotas). */
dueAt: string;
- /** Days past dueAt (0 if not yet due). */
+ /** Days past dueAt (0 while the deadline is still ahead). */
overdueDays: number;
- /** Time past due, in units of the routine's own frequency (0 if not due). */
+ /**
+ * Overdue severity in units of the routine's own period: interval = time
+ * past due; quota = trailing missed windows + elapsed window fraction.
+ */
overdueRatio: number;
status: RoutineStatus;
/** 100 on schedule, 50 one period overdue, 0 at two periods. */
@@ -84,14 +94,25 @@ export function computeRoutineState(
completions: CompletionInput[],
now: Temporal.Instant,
): RoutineState {
- const lastDone = maxInstant(parseCompletions(routine.id, completions));
+ const freq = Frequency.parse(routine.frequency);
+ const doneAt = parseCompletions(routine.id, completions);
+ const lastDone = maxInstant(doneAt);
+ if (freq.count !== null) {
+ const days = freq.periodDays();
+ const origin = parseInstant(routine.createdAt, now)
+ .toZonedDateTimeISO(UTC)
+ .startOfDay();
+ const q = quotaProgress(freq.count, days, doneAt, now, origin.toInstant());
+ return quotaState(routine, freq.count, days, q, now, lastDone, origin);
+ }
+ const days = freq.periodDays();
const reference = lastDone ?? parseInstant(routine.createdAt, now);
- const period = periodMs(routine.frequencyDays);
+ const period = periodMs(days);
// Calendar-day anchor: the period starts at the reference day's midnight.
const due = reference
.toZonedDateTimeISO(UTC)
.startOfDay()
- .add({ days: lastDone === null ? 0 : routine.frequencyDays })
+ .add({ days: lastDone === null ? 0 : days })
.toInstant();
const rawRatio = (now.epochMilliseconds - due.epochMilliseconds) / period;
const overdueRatio = Math.max(0, rawRatio);
@@ -108,10 +129,105 @@ export function computeRoutineState(
id: routine.id,
version: routine.version,
name: routine.name,
- frequencyDays: routine.frequencyDays,
+ frequency: routine.frequency,
+ lastDoneAt: lastDone !== null ? iso(lastDone) : null,
+ dueAt: iso(due),
+ overdueDays: Math.floor(overdueRatio * days),
+ overdueRatio,
+ status,
+ health: Math.max(0, Math.round(100 - overdueRatio * 50)),
+ };
+}
+
+interface QuotaProgress {
+ /** Completions inside the current window (by doneAt; backdating-safe). */
+ done: number;
+ /** Consecutive unmet windows ending just before the current one. */
+ missed: number;
+ /** Elapsed fraction of the current window. */
+ elapsed: number;
+ /** Index of the current window since creation day. */
+ window: number;
+}
+
+/**
+ * Routine-anchored quota windows: window j spans
+ * [startOfDay(createdAt) + j·days, +days). A completion counts toward the
+ * window containing its doneAt; completions before creation count toward
+ * window 0 and future-dated ones are ignored.
+ */
+function quotaProgress(
+ quota: number,
+ days: number,
+ doneAt: Temporal.Instant[],
+ t: Temporal.Instant,
+ origin: Temporal.Instant,
+): QuotaProgress {
+ const period = periodMs(days);
+ const window = Math.max(
+ 0,
+ Math.floor((t.epochMilliseconds - origin.epochMilliseconds) / period),
+ );
+ const counts = new Map<number, number>();
+ for (const d of doneAt) {
+ if (Temporal.Instant.compare(d, t) > 0) continue;
+ const j = Math.max(
+ 0,
+ Math.floor((d.epochMilliseconds - origin.epochMilliseconds) / period),
+ );
+ counts.set(j, (counts.get(j) ?? 0) + 1);
+ }
+ let missed = 0;
+ for (let j = window - 1; j >= 0; j--) {
+ if ((counts.get(j) ?? 0) >= quota) break;
+ missed++;
+ }
+ return {
+ done: counts.get(window) ?? 0,
+ missed,
+ elapsed:
+ (t.epochMilliseconds - origin.epochMilliseconds - window * period) /
+ period,
+ window,
+ };
+}
+
+function quotaState(
+ routine: RoutineInput,
+ quota: number,
+ days: number,
+ q: QuotaProgress,
+ now: Temporal.Instant,
+ lastDone: Temporal.Instant | null,
+ origin: Temporal.ZonedDateTime,
+): RoutineState {
+ const met = q.done >= quota;
+ const overdueRatio = met ? 0 : Math.max(0, q.missed + q.elapsed);
+ const status: RoutineStatus = met
+ ? 'ok'
+ : overdueRatio < 1
+ ? 'due'
+ : overdueRatio < 2
+ ? 'late'
+ : 'veryLate';
+ // Deadline of the pending obligation: the oldest unmet window's end while
+ // behind, the current window's end while catching up, the next one when met.
+ const due = origin
+ .add({
+ days: (met ? q.window + 2 : q.window - q.missed + 1) * days,
+ })
+ .toInstant();
+ return {
+ id: routine.id,
+ version: routine.version,
+ name: routine.name,
+ frequency: routine.frequency,
lastDoneAt: lastDone !== null ? iso(lastDone) : null,
dueAt: iso(due),
- overdueDays: Math.floor(overdueRatio * routine.frequencyDays),
+ overdueDays: Math.max(
+ 0,
+ Math.floor((now.epochMilliseconds - due.epochMilliseconds) / periodMs(1)),
+ ),
overdueRatio,
status,
health: Math.max(0, Math.round(100 - overdueRatio * 50)),
@@ -149,12 +265,37 @@ function healthAtPoint(
return Math.max(0, Math.round(100 - Math.max(0, rawRatio) * 50));
}
+/** Health at t: interval anchor formula or quota window formula. */
+function healthAt(
+ freq: Frequency,
+ doneAt: Temporal.Instant[],
+ t: Temporal.Instant,
+ created: Temporal.Instant,
+): number {
+ if (freq.count !== null) {
+ const origin = created.toZonedDateTimeISO(UTC).startOfDay().toInstant();
+ const q = quotaProgress(freq.count, freq.periodDays(), doneAt, t, origin);
+ const overdueRatio =
+ q.done >= freq.count ? 0 : Math.max(0, q.missed + q.elapsed);
+ return Math.max(0, Math.round(100 - overdueRatio * 50));
+ }
+ let reference = created;
+ let neverDone = true;
+ for (const d of doneAt) {
+ if (Temporal.Instant.compare(d, t) > 0) break;
+ reference = d;
+ neverDone = false;
+ }
+ return healthAtPoint(reference, freq.periodDays(), t, neverDone);
+}
+
/** Daily health samples: 100 at completion, decaying while overdue. */
export function computeHealthSeries(
routine: RoutineInput,
completions: CompletionInput[],
now: Temporal.Instant,
): HealthPoint[] {
+ const freq = Frequency.parse(routine.frequency);
const start = parseInstant(routine.createdAt, now);
const doneAt = parseCompletions(routine.id, completions);
@@ -163,36 +304,23 @@ export function computeHealthSeries(
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) });
+ points.push({
+ at: t.epochMilliseconds,
+ health: healthAt(freq, doneAt, t, start),
+ });
}
// 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) });
+ points.push({
+ at: now.epochMilliseconds,
+ health: healthAt(freq, doneAt, now, start),
+ });
}
return points;
}
@@ -208,48 +336,38 @@ export function computeProjectHealthSeries(
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.
- }
+ const t = rawInstant(c.doneAt);
+ if (t === null) continue; // Skip corrupt stamps instead of poisoning the anchor.
+ completionsByRoutine.set(c.routineId, [
+ ...(completionsByRoutine.get(c.routineId) ?? []),
+ t,
+ ]);
}
for (const times of completionsByRoutine.values())
times.sort(Temporal.Instant.compare);
- const createdOrNow = (r: RoutineInput): Temporal.Instant =>
- parseInstant(r.createdAt, now);
+ const parsed = routines.map((r) => ({
+ born: rawInstant(r.createdAt),
+ freq: Frequency.parse(r.frequency),
+ created: parseInstant(r.createdAt, now),
+ doneAt: completionsByRoutine.get(r.id) ?? [],
+ }));
+
// 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;
- }
- });
+ const alive = parsed.filter(
+ (p) => p.born !== null && Temporal.Instant.compare(p.born, t) <= 0,
+ );
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);
+ const total = alive.reduce(
+ (sum, p) => sum + healthAt(p.freq, p.doneAt, t, p.created),
+ 0,
+ );
return Math.round(total / alive.length);
};
- const start = routines
- .map(createdOrNow)
+ const start = parsed
+ .map((p) => p.created)
.reduce((a, b) => (Temporal.Instant.compare(a, b) <= 0 ? a : b));
if (Temporal.Instant.compare(now, start) <= 0)
return [{ at: now.epochMilliseconds, health: 100 }];
@@ -272,27 +390,6 @@ export function computeProjectHealthSeries(
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',