aboutsummaryrefslogtreecommitdiff
path: root/src/lib/shared
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/shared')
-rw-r--r--src/lib/shared/routine-metrics.test.ts105
-rw-r--r--src/lib/shared/routine-metrics.ts113
2 files changed, 218 insertions, 0 deletions
diff --git a/src/lib/shared/routine-metrics.test.ts b/src/lib/shared/routine-metrics.test.ts
new file mode 100644
index 0000000..4a84eda
--- /dev/null
+++ b/src/lib/shared/routine-metrics.test.ts
@@ -0,0 +1,105 @@
+import { describe, it, expect } from 'vitest';
+import {
+ computeRoutineState,
+ computeHealthScore,
+ parseIntervalToDays,
+} from './routine-metrics';
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+const CREATED = '2026-09-01T00:00:00.000Z';
+
+const routine = {
+ id: 1,
+ version: 1,
+ name: 'clean kitchen',
+ frequencyDays: 7,
+ createdAt: CREATED,
+};
+
+describe('computeRoutineState', () => {
+ it('is ok and healthy before the due date', () => {
+ const now = Date.parse(CREATED) + 3 * DAY_MS;
+ const state = computeRoutineState(routine, [], now);
+ expect(state.status).toBe('ok');
+ expect(state.overdueDays).toBe(0);
+ expect(state.health).toBe(100);
+ expect(state.dueAt).toBe(
+ new Date(Date.parse(CREATED) + 7 * DAY_MS).toISOString(),
+ );
+ });
+
+ it('uses the last completion as reference once done', () => {
+ const doneAt = new Date(Date.parse(CREATED) + 5 * DAY_MS).toISOString();
+ const now = Date.parse(doneAt) + 2 * DAY_MS;
+ const state = computeRoutineState(routine, [{ routineId: 1, doneAt }], now);
+ expect(state.lastDoneAt).toBe(doneAt);
+ expect(state.status).toBe('ok');
+ });
+
+ it('is due at exactly the frequency', () => {
+ const now = Date.parse(CREATED) + 7 * DAY_MS;
+ const state = computeRoutineState(routine, [], now);
+ expect(state.status).toBe('due');
+ expect(state.health).toBe(100);
+ });
+
+ it('is very urgent (veryLate) when two weeks old for a weekly routine', () => {
+ const now = Date.parse(CREATED) + 15 * DAY_MS;
+ const state = computeRoutineState(routine, [], now);
+ expect(state.status).toBe('veryLate');
+ expect(state.overdueDays).toBe(8);
+ expect(state.health).toBe(0);
+ });
+
+ it('is late when overdue by half a frequency', () => {
+ const now = Date.parse(CREATED) + 10.5 * DAY_MS;
+ const state = computeRoutineState(routine, [], now);
+ expect(state.status).toBe('late');
+ });
+
+ it('health of 75 means a quarter of the period overdue', () => {
+ const now = Date.parse(CREATED) + 7 * DAY_MS + 1.75 * DAY_MS;
+ const state = computeRoutineState(routine, [], now);
+ expect(state.health).toBe(75);
+ });
+});
+
+describe('parseIntervalToDays', () => {
+ it('converts units to days', () => {
+ expect(parseIntervalToDays('1d')).toBe(1);
+ expect(parseIntervalToDays('2w')).toBe(14);
+ expect(parseIntervalToDays('3m')).toBe(90);
+ expect(parseIntervalToDays('1y')).toBe(365);
+ });
+
+ it('accepts whitespace and uppercase', () => {
+ expect(parseIntervalToDays(' 2W ')).toBe(14);
+ });
+
+ it('rejects invalid input', () => {
+ expect(parseIntervalToDays('2')).toBeNull();
+ expect(parseIntervalToDays('w')).toBeNull();
+ expect(parseIntervalToDays('2x')).toBeNull();
+ expect(parseIntervalToDays('-1d')).toBeNull();
+ expect(parseIntervalToDays('')).toBeNull();
+ });
+});
+
+describe('computeHealthScore', () => {
+ it('is 100 with no routines', () => {
+ expect(computeHealthScore([])).toBe(100);
+ });
+
+ it('averages routine healths', () => {
+ const now = Date.parse(CREATED) + 8 * DAY_MS;
+ const a = computeRoutineState(routine, [], now); // overdue 1 day
+ const b = computeRoutineState(
+ routine,
+ [],
+ Date.parse(CREATED) + 3 * DAY_MS,
+ );
+ expect(computeHealthScore([a, b])).toBe(
+ Math.round((a.health + b.health) / 2),
+ );
+ });
+});
diff --git a/src/lib/shared/routine-metrics.ts b/src/lib/shared/routine-metrics.ts
new file mode 100644
index 0000000..2ac245e
--- /dev/null
+++ b/src/lib/shared/routine-metrics.ts
@@ -0,0 +1,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',
+};