diff options
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | src/lib/shared/routine-metrics.test.ts | 48 | ||||
| -rw-r--r-- | src/lib/shared/routine-metrics.ts | 21 |
3 files changed, 22 insertions, 49 deletions
@@ -85,7 +85,7 @@ Seed from the paper sheet (one-off, idempotent): - [ ] Live updates from server - [ ] Auto-categorize new items (LLM-assisted) - [ ] https://automerge.org/docs/tutorial/ -- [ ] Style like feed.kagamino.dev +- [ ] Style like feed.kagamino.dev? - [ ] Shared > Modal actions - [ ] Routine > Frequency = 5d/1w diff --git a/src/lib/shared/routine-metrics.test.ts b/src/lib/shared/routine-metrics.test.ts index b3b84a2..64b5da9 100644 --- a/src/lib/shared/routine-metrics.test.ts +++ b/src/lib/shared/routine-metrics.test.ts @@ -29,13 +29,13 @@ const routine = { }; describe('computeRoutineState', () => { - it('is due when never done, even before the due date', () => { + it('is due immediately at creation when never done (created_at = due)', () => { const now = plusDays(created, 3); const state = computeRoutineState(routine, [], now); expect(state.status).toBe('due'); - expect(state.overdueDays).toBe(0); - expect(state.health).toBe(100); - expect(state.dueAt).toBe(isoPlusDays(CREATED, 7)); + expect(state.overdueDays).toBe(3); + expect(state.health).toBe(79); // 3/7 of a period overdue + expect(state.dueAt).toBe(CREATED); }); it('uses the last completion as reference once done', () => { @@ -46,43 +46,6 @@ describe('computeRoutineState', () => { expect(state.status).toBe('ok'); }); - it('is due at exactly the frequency', () => { - const now = plusDays(created, 7); - const state = computeRoutineState(routine, [], now); - expect(state.status).toBe('due'); - expect(state.health).toBe(100); - }); - - it('is very urgent (veryLate) when two full periods overdue', () => { - const now = plusDays(created, 22); - const state = computeRoutineState(routine, [], now); - expect(state.status).toBe('veryLate'); - expect(state.overdueDays).toBe(15); - expect(state.health).toBe(0); - }); - - it('is due (not late) when overdue by half a frequency', () => { - const now = created - .toZonedDateTimeISO('UTC') - .add({ days: 10 }) - .toInstant() - .add({ hours: 12 }); - const state = computeRoutineState(routine, [], now); - expect(state.status).toBe('due'); - }); - - it('is late when a full period overdue', () => { - const now = plusDays(created, 14); - const state = computeRoutineState(routine, [], now); - expect(state.status).toBe('late'); - expect(state.health).toBe(50); - }); - - it('health of 50 means a full period overdue', () => { - const now = plusDays(created, 14); - const state = computeRoutineState(routine, [], now); - expect(state.health).toBe(50); - }); it('is due the calendar day after completion for a daily routine', () => { const daily = { ...routine, frequencyDays: 1 }; const doneAt = '2026-09-07T20:00:00.000Z'; // yesterday evening @@ -90,7 +53,6 @@ describe('computeRoutineState', () => { 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 doneAt = '2026-09-07T20:00:00.000Z'; // yesterday evening @@ -126,7 +88,7 @@ describe('computeHealthSeries', () => { const at8d = series.find( (p) => p.at === plusDays(created, 8).epochMilliseconds, ); - expect(at8d?.health).toBe(93); // 1/7 of a period overdue + expect(at8d?.health).toBe(43); // 8/7 of a period overdue const last = series[series.length - 1]; expect(last.at).toBe(now.epochMilliseconds); expect(last.health).toBe(0); // more than two full periods overdue diff --git a/src/lib/shared/routine-metrics.ts b/src/lib/shared/routine-metrics.ts index 953a025..7758f27 100644 --- a/src/lib/shared/routine-metrics.ts +++ b/src/lib/shared/routine-metrics.ts @@ -92,10 +92,12 @@ export function computeRoutineState( const period = periodMs(routine.frequencyDays); // Calendar-day anchor: the period starts at midnight of the reference day, // so any new calendar day past the frequency flips the routine to due. + // A never-done routine has no completed period: its creation day is its + // due date, so it ages exactly like a done one whose anchor is creation. const due = reference .toZonedDateTimeISO(UTC) .startOfDay() - .add({ days: routine.frequencyDays }) + .add({ days: lastDone === null ? 0 : routine.frequencyDays }) .toInstant(); const rawRatio = (now.epochMilliseconds - due.epochMilliseconds) / period; const overdueRatio = Math.max(0, rawRatio); @@ -141,11 +143,12 @@ function healthAtPoint( reference: Temporal.Instant, frequencyDays: number, t: Temporal.Instant, + neverDone = false, ): number { const due = reference .toZonedDateTimeISO(UTC) .startOfDay() - .add({ days: frequencyDays }) + .add({ days: neverDone ? 0 : frequencyDays }) .toInstant(); const rawRatio = (t.epochMilliseconds - due.epochMilliseconds) / periodMs(frequencyDays); @@ -156,7 +159,8 @@ function healthAtPoint( * Daily health samples from the routine's creation to now: 100 right after a * 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). + * Each sample anchors on the latest completion at or before it (or creation, + * which is then itself the due date for a never-done routine). * Uses the routine's current frequency across the whole history. */ export function computeHealthSeries( @@ -183,7 +187,12 @@ export function computeHealthSeries( reference = doneAt[nextCompletion]; nextCompletion++; } - return healthAtPoint(reference, routine.frequencyDays, t); + return healthAtPoint( + reference, + routine.frequencyDays, + t, + nextCompletion === 0, + ); }; for ( @@ -250,7 +259,9 @@ export function computeProjectHealthSeries( ); const anchor = doneAt.length > 0 ? (maxInstant(doneAt) ?? now) : createdOrNow(r); - return sum + healthAtPoint(anchor, r.frequencyDays, t); + return ( + sum + healthAtPoint(anchor, r.frequencyDays, t, doneAt.length === 0) + ); }, 0); return Math.round(total / alive.length); }; |
