aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/lib/shared/routine-metrics.test.ts48
-rw-r--r--src/lib/shared/routine-metrics.ts21
2 files changed, 21 insertions, 48 deletions
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);
};