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.ts231
1 files changed, 207 insertions, 24 deletions
diff --git a/src/lib/shared/routine-metrics.test.ts b/src/lib/shared/routine-metrics.test.ts
index 5bc0a78..88d0498 100644
--- a/src/lib/shared/routine-metrics.test.ts
+++ b/src/lib/shared/routine-metrics.test.ts
@@ -4,7 +4,7 @@ import {
computeHealthScore,
computeHealthSeries,
computeProjectHealthSeries,
- parseIntervalToDays,
+ type HealthPoint,
} from './routine-metrics';
const CREATED = '2026-09-01T00:00:00.000Z';
const created = Temporal.Instant.from(CREATED);
@@ -24,10 +24,22 @@ const routine = {
id: 1,
version: 1,
name: 'clean kitchen',
- frequencyDays: 7,
+ frequency: '7d',
createdAt: CREATED,
};
+const quotaRoutine = {
+ id: 1,
+ version: 1,
+ name: 'hoover',
+ frequency: '5/2w',
+ createdAt: '2026-09-07T00:00:00.000Z', // windows: [09-07, 09-21), [09-21, 10-05), …
+};
+
+/** Completion inputs for routine 1 from ISO stamps. */
+const done = (...stamps: string[]) =>
+ stamps.map((doneAt) => ({ routineId: 1, doneAt }));
+
describe('computeRoutineState', () => {
it('is due immediately at creation when never done (created_at = due)', () => {
const now = plusDays(created, 3);
@@ -47,14 +59,14 @@ describe('computeRoutineState', () => {
});
it('is due the calendar day after completion for a daily routine', () => {
- const daily = { ...routine, frequencyDays: 1 };
+ const daily = { ...routine, frequency: '1d' };
const doneAt = '2026-09-07T20:00:00.000Z'; // yesterday evening
const now = Temporal.Instant.from('2026-09-08T08:00:00.000Z'); // <24h later
const state = computeRoutineState(daily, [{ routineId: 1, doneAt }], now);
expect(state.status).toBe('due');
});
it('a daily done yesterday is late tomorrow, not today', () => {
- const daily = { ...routine, frequencyDays: 1 };
+ const daily = { ...routine, frequency: '1d' };
const doneAt = '2026-09-07T20:00:00.000Z'; // yesterday evening
const completions = [{ routineId: 1, doneAt }];
const today = computeRoutineState(
@@ -72,7 +84,7 @@ describe('computeRoutineState', () => {
});
it('is ok later the same day it was done for a daily routine', () => {
- const daily = { ...routine, frequencyDays: 1 };
+ const daily = { ...routine, frequency: '1d' };
const doneAt = '2026-09-08T08:00:00.000Z';
const now = Temporal.Instant.from('2026-09-08T20:00:00.000Z');
const state = computeRoutineState(daily, [{ routineId: 1, doneAt }], now);
@@ -80,6 +92,154 @@ describe('computeRoutineState', () => {
});
});
+describe('computeRoutineState (quota)', () => {
+ it('is ok at quota met within the window', () => {
+ const state = computeRoutineState(
+ quotaRoutine,
+ done(
+ '2026-09-07T08:00:00.000Z',
+ '2026-09-08T08:00:00.000Z',
+ '2026-09-09T08:00:00.000Z',
+ '2026-09-10T08:00:00.000Z',
+ '2026-09-11T08:00:00.000Z',
+ ),
+ Temporal.Instant.from('2026-09-12T12:00:00.000Z'),
+ );
+ expect(state.status).toBe('ok');
+ expect(state.health).toBe(100);
+ expect(state.overdueRatio).toBe(0);
+ expect(state.overdueDays).toBe(0);
+ expect(state.frequency).toBe('5/2w');
+ expect(state.dueAt).toBe('2026-10-05T00:00:00.000Z');
+ });
+
+ it('is due while the window is open and the quota unmet', () => {
+ const state = computeRoutineState(
+ quotaRoutine,
+ done('2026-09-07T08:00:00.000Z', '2026-09-08T09:00:00.000Z'),
+ Temporal.Instant.from('2026-09-09T12:00:00.000Z'),
+ );
+ expect(state.status).toBe('due');
+ expect(state.health).toBe(91); // 2.5/14 of the window elapsed
+ expect(state.overdueDays).toBe(0); // deadline 09-21 still ahead
+ expect(state.dueAt).toBe('2026-09-21T00:00:00.000Z');
+ });
+
+ it('is late as soon as the window closes unmet', () => {
+ const state = computeRoutineState(
+ quotaRoutine,
+ done('2026-09-07T08:00:00.000Z', '2026-09-08T09:00:00.000Z'),
+ Temporal.Instant.from('2026-09-21T00:05:00.000Z'),
+ );
+ expect(state.status).toBe('late');
+ expect(state.health).toBe(50); // one whole window behind
+ expect(state.overdueDays).toBe(0);
+ expect(state.dueAt).toBe('2026-09-21T00:00:00.000Z');
+ });
+
+ it('accumulates overdue days while behind', () => {
+ const state = computeRoutineState(
+ quotaRoutine,
+ done('2026-09-07T08:00:00.000Z', '2026-09-08T09:00:00.000Z'),
+ Temporal.Instant.from('2026-09-22T12:00:00.000Z'),
+ );
+ expect(state.status).toBe('late');
+ expect(state.health).toBe(45); // 1.5/14 of the window elapsed behind
+ expect(state.overdueDays).toBe(1);
+ expect(state.dueAt).toBe('2026-09-21T00:00:00.000Z');
+ });
+
+ it('is very late after two closed unmet windows', () => {
+ const state = computeRoutineState(
+ quotaRoutine,
+ done('2026-09-07T08:00:00.000Z', '2026-09-08T09:00:00.000Z'),
+ Temporal.Instant.from('2026-10-05T00:00:05.000Z'),
+ );
+ expect(state.status).toBe('veryLate');
+ expect(state.health).toBe(0);
+ expect(state.overdueDays).toBe(14);
+ expect(state.dueAt).toBe('2026-09-21T00:00:00.000Z');
+ });
+
+ it('quota met beats a previous window shortfall', () => {
+ const state = computeRoutineState(
+ quotaRoutine,
+ done(
+ '2026-09-08T08:00:00.000Z',
+ '2026-09-09T08:00:00.000Z',
+ '2026-09-10T08:00:00.000Z',
+ '2026-09-22T08:00:00.000Z',
+ '2026-09-23T08:00:00.000Z',
+ '2026-09-24T08:00:00.000Z',
+ '2026-09-25T08:00:00.000Z',
+ '2026-09-26T08:00:00.000Z',
+ ),
+ Temporal.Instant.from('2026-09-26T12:00:00.000Z'),
+ );
+ expect(state.status).toBe('ok');
+ expect(state.health).toBe(100);
+ expect(state.dueAt).toBe('2026-10-19T00:00:00.000Z');
+ });
+
+ it('counts a completion at the window start in the new window', () => {
+ const state = computeRoutineState(
+ quotaRoutine,
+ done(
+ '2026-09-08T08:00:00.000Z',
+ '2026-09-09T08:00:00.000Z',
+ '2026-09-10T08:00:00.000Z',
+ '2026-09-11T08:00:00.000Z',
+ '2026-09-21T00:00:00.000Z',
+ ),
+ Temporal.Instant.from('2026-09-21T00:05:00.000Z'),
+ );
+ // 4/5 in window 0 (missed); the fifth landed in window 1.
+ expect(state.status).toBe('late');
+ expect(state.health).toBe(50);
+ });
+
+ it('counts a completion at the window end in the old window', () => {
+ const state = computeRoutineState(
+ quotaRoutine,
+ done(
+ '2026-09-08T08:00:00.000Z',
+ '2026-09-09T08:00:00.000Z',
+ '2026-09-10T08:00:00.000Z',
+ '2026-09-11T08:00:00.000Z',
+ '2026-09-20T23:59:59.999Z',
+ ),
+ Temporal.Instant.from('2026-09-21T00:05:00.000Z'),
+ );
+ // 5/5 in window 0: window 1 starts unmet but not behind.
+ expect(state.status).toBe('due');
+ expect(state.health).toBe(100);
+ expect(state.dueAt).toBe('2026-10-05T00:00:00.000Z');
+ });
+
+ it('counts backdated completions in the window containing doneAt', () => {
+ const twoPerFortnight = { ...quotaRoutine, frequency: '2/2w' };
+ const state = computeRoutineState(
+ twoPerFortnight,
+ done('2026-09-08T08:00:00.000Z', '2026-09-09T09:00:00.000Z'),
+ Temporal.Instant.from('2026-09-22T12:00:00.000Z'),
+ );
+ // Window 0 is met (2/2) even though both were logged long after;
+ // window 1 is open and unmet.
+ expect(state.status).toBe('due');
+ expect(state.health).toBe(95);
+ });
+
+ it('throws on a spec that cannot parse', () => {
+ expect(() =>
+ computeRoutineState(
+ { ...quotaRoutine, frequency: '5/w' },
+ [],
+ Temporal.Instant.from('2026-09-09T12:00:00.000Z'),
+ ),
+ ).toThrow('Invalid frequency spec');
+ });
+});
+
describe('computeHealthSeries', () => {
it('decays from 100 at creation to 0 two periods overdue when never done', () => {
const now = plusDays(created, 22);
@@ -132,6 +292,40 @@ describe('computeHealthSeries', () => {
});
});
+describe('computeHealthSeries (quota)', () => {
+ const healthOn = (series: HealthPoint[], iso: string) =>
+ series.find((p) => p.at === Temporal.Instant.from(iso).epochMilliseconds)
+ ?.health;
+
+ it('decays within a window, then drops a whole window per miss', () => {
+ const now = Temporal.Instant.from('2026-10-05T00:00:00.000Z');
+ const series = computeHealthSeries(quotaRoutine, [], now);
+ expect(series[0].health).toBe(100); // window open at creation
+ expect(healthOn(series, '2026-09-14T00:00:00.000Z')).toBe(75); // half window
+ expect(healthOn(series, '2026-09-21T00:00:00.000Z')).toBe(50); // 1 window missed
+ expect(healthOn(series, '2026-09-28T00:00:00.000Z')).toBe(25);
+ expect(healthOn(series, '2026-10-05T00:00:00.000Z')).toBe(0); // 2 missed
+ });
+
+ it('resets to 100 once the quota is met', () => {
+ const now = Temporal.Instant.from('2026-09-13T00:00:00.000Z');
+ const series = computeHealthSeries(
+ quotaRoutine,
+ done(
+ '2026-09-07T00:00:00.000Z',
+ '2026-09-08T00:00:00.000Z',
+ '2026-09-09T00:00:00.000Z',
+ '2026-09-10T00:00:00.000Z',
+ '2026-09-11T00:00:00.000Z',
+ ),
+ now,
+ );
+ expect(healthOn(series, '2026-09-10T00:00:00.000Z')).toBe(89); // 4/5
+ expect(healthOn(series, '2026-09-11T00:00:00.000Z')).toBe(100); // 5/5
+ expect(healthOn(series, '2026-09-12T00:00:00.000Z')).toBe(100); // met
+ });
+});
+
describe('computeProjectHealthSeries', () => {
it('is empty with no routines', () => {
expect(computeProjectHealthSeries([], [], created)).toEqual([]);
@@ -171,26 +365,15 @@ describe('computeProjectHealthSeries', () => {
const single = computeHealthSeries(routine, [], plusDays(created, 5));
expect(beforeLate?.health).toBe(single[single.length - 1].health);
});
-});
-
-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();
+ it('averages quota routines with the same formula', () => {
+ const now = Temporal.Instant.from('2026-09-09T12:00:00.000Z');
+ const series = computeProjectHealthSeries([quotaRoutine], [], now);
+ const states = [computeRoutineState(quotaRoutine, [], now)];
+ const last = series[series.length - 1];
+ expect(last.at).toBe(now.epochMilliseconds);
+ expect(last.health).toBe(computeHealthScore(states));
+ expect(last.health).toBe(91);
});
});