diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-08 15:53:07 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-08 15:53:07 +0200 |
| commit | 6d69c7b1e6a8051338e052cc9d1836f4f1a20f51 (patch) | |
| tree | 33af9e78d93afe34f113aad116d3e5e4ed2a5fc1 /src/lib | |
| parent | 212c3adc8a584e6517a2816fc6683688d5ac5d7a (diff) | |
Late only after a full missed period
Status buckets shift a full period out: due until an occurrence is
skipped entirely, late after one, very late after two. Health decay
halves (100 - ratio*50) so 50 marks late and 0 marks veryLate.
Diffstat (limited to 'src/lib')
| -rw-r--r-- | src/lib/server/services/routines.test.ts | 5 | ||||
| -rw-r--r-- | src/lib/shared/routine-metrics.test.ts | 49 | ||||
| -rw-r--r-- | src/lib/shared/routine-metrics.ts | 19 |
3 files changed, 49 insertions, 24 deletions
diff --git a/src/lib/server/services/routines.test.ts b/src/lib/server/services/routines.test.ts index 04151c2..22b49ff 100644 --- a/src/lib/server/services/routines.test.ts +++ b/src/lib/server/services/routines.test.ts @@ -134,9 +134,8 @@ describe('routines service', () => { const r = await add(projectId, 'clean kitchen', 7, db); const createdAt = Date.parse(r.createdAt); await complete(r.id, null, new Date(createdAt).toISOString(), db); - - // 15 days after completion with a 7-day routine => very late - const now = createdAt + 15 * DAY_MS; + // 22 days after completion with a 7-day routine => two periods overdue + const now = createdAt + 22 * DAY_MS; const dashboard = await loadProjectDashboard(projectId, now, db); expect(dashboard.routines).toHaveLength(1); expect(dashboard.routines[0].status).toBe('veryLate'); diff --git a/src/lib/shared/routine-metrics.test.ts b/src/lib/shared/routine-metrics.test.ts index 57b8c38..d18fd18 100644 --- a/src/lib/shared/routine-metrics.test.ts +++ b/src/lib/shared/routine-metrics.test.ts @@ -45,24 +45,31 @@ describe('computeRoutineState', () => { 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; + it('is very urgent (veryLate) when two full periods overdue', () => { + const now = Date.parse(CREATED) + 22 * DAY_MS; const state = computeRoutineState(routine, [], now); expect(state.status).toBe('veryLate'); - expect(state.overdueDays).toBe(8); + expect(state.overdueDays).toBe(15); expect(state.health).toBe(0); }); - it('is late when overdue by half a frequency', () => { + it('is due (not 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('due'); + }); + + it('is late when a full period overdue', () => { + const now = Date.parse(CREATED) + 14 * DAY_MS; + const state = computeRoutineState(routine, [], now); expect(state.status).toBe('late'); + expect(state.health).toBe(50); }); - it('health of 75 means a quarter of the period overdue', () => { - const now = Date.parse(CREATED) + 7 * DAY_MS + 1.75 * DAY_MS; + it('health of 50 means a full period overdue', () => { + const now = Date.parse(CREATED) + 7 * DAY_MS + 7 * DAY_MS; const state = computeRoutineState(routine, [], now); - expect(state.health).toBe(75); + expect(state.health).toBe(50); }); it('is due the calendar day after completion for a daily routine', () => { const daily = { ...routine, frequencyDays: 1 }; @@ -72,6 +79,24 @@ describe('computeRoutineState', () => { expect(state.status).toBe('due'); }); + it('a daily done yesterday is late tomorrow, not today', () => { + const daily = { ...routine, frequencyDays: 1 }; + const doneAt = '2026-09-07T20:00:00.000Z'; // yesterday evening + const completions = [{ routineId: 1, doneAt }]; + const today = computeRoutineState( + daily, + completions, + Date.parse('2026-09-08T20:00:00.000Z'), + ); + expect(today.status).toBe('due'); + const tomorrow = computeRoutineState( + daily, + completions, + Date.parse('2026-09-09T20:00:00.000Z'), + ); + expect(tomorrow.status).toBe('late'); + }); + it('is ok later the same day it was done for a daily routine', () => { const daily = { ...routine, frequencyDays: 1 }; const doneAt = '2026-09-08T08:00:00.000Z'; @@ -82,15 +107,15 @@ describe('computeRoutineState', () => { }); describe('computeHealthSeries', () => { - it('decays from 100 at creation to 0 two periods late when never done', () => { - const now = Date.parse(CREATED) + 15 * DAY_MS; + it('decays from 100 at creation to 0 two periods overdue when never done', () => { + const now = Date.parse(CREATED) + 22 * DAY_MS; const series = computeHealthSeries(routine, [], now); expect(series[0].health).toBe(100); const at8d = series.find((p) => p.at === Date.parse(CREATED) + 8 * DAY_MS); - expect(at8d?.health).toBe(86); // 1/7 of a period overdue + expect(at8d?.health).toBe(93); // 1/7 of a period overdue const last = series[series.length - 1]; expect(last.at).toBe(now); - expect(last.health).toBe(0); // more than a full period overdue + expect(last.health).toBe(0); // more than two full periods overdue }); it('resets to 100 right after a completion, then decays again (sawtooth)', () => { @@ -105,7 +130,7 @@ describe('computeHealthSeries', () => { expect(rightAfter?.health).toBe(100); // 8 days after the completion: 1 day into the 7-day period overdue. const at8d = series.find((p) => p.at === doneAt + 8 * DAY_MS); - expect(at8d?.health).toBe(86); + expect(at8d?.health).toBe(93); }); it('a second completion resets the decay again', () => { diff --git a/src/lib/shared/routine-metrics.ts b/src/lib/shared/routine-metrics.ts index 891a0c9..cffeb85 100644 --- a/src/lib/shared/routine-metrics.ts +++ b/src/lib/shared/routine-metrics.ts @@ -39,7 +39,7 @@ export interface RoutineState { /** 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. */ + /** 100 on schedule, 50 when a full period overdue, 0 at two periods, floor at 0. */ health: number; } @@ -61,17 +61,17 @@ export function computeRoutineState( 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: - // not due / due now / late (half a period overdue) / very late (a full - // period overdue — a weekly routine two weeks old lands here). + // 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 < 0.5 + : overdueRatio < 1 ? 'due' - : overdueRatio < 1 + : overdueRatio < 2 ? 'late' : 'veryLate'; @@ -85,7 +85,7 @@ export function computeRoutineState( overdueDays: Math.floor(overdueRatio * routine.frequencyDays), overdueRatio, status, - health: Math.max(0, Math.round(100 - overdueRatio * 100)), + health: Math.max(0, Math.round(100 - overdueRatio * 50)), }; } @@ -107,12 +107,13 @@ export interface HealthPoint { 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) * 100)); + 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 a full period is overdue (sawtooth). + * 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. */ |
