aboutsummaryrefslogtreecommitdiff
path: root/src/lib/shared/routine-metrics.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/shared/routine-metrics.test.ts')
-rw-r--r--src/lib/shared/routine-metrics.test.ts105
1 files changed, 105 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),
+ );
+ });
+});