aboutsummaryrefslogtreecommitdiff
path: root/src/lib/shared
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/shared')
-rw-r--r--src/lib/shared/routine-metrics.test.ts116
-rw-r--r--src/lib/shared/routine-metrics.ts207
-rw-r--r--src/lib/shared/temporal.ts27
3 files changed, 221 insertions, 129 deletions
diff --git a/src/lib/shared/routine-metrics.test.ts b/src/lib/shared/routine-metrics.test.ts
index d18fd18..b3b84a2 100644
--- a/src/lib/shared/routine-metrics.test.ts
+++ b/src/lib/shared/routine-metrics.test.ts
@@ -6,9 +6,19 @@ import {
computeProjectHealthSeries,
parseIntervalToDays,
} from './routine-metrics';
-
-const DAY_MS = 24 * 60 * 60 * 1000;
const CREATED = '2026-09-01T00:00:00.000Z';
+const created = Temporal.Instant.from(CREATED);
+
+const plusDays = (base: Temporal.Instant, days: number): Temporal.Instant =>
+ base.toZonedDateTimeISO('UTC').add({ days }).toInstant();
+
+/** ISO stamp n days after the given stamp. */
+const isoPlusDays = (iso: string, days: number): string =>
+ Temporal.Instant.from(iso)
+ .toZonedDateTimeISO('UTC')
+ .add({ days })
+ .toInstant()
+ .toString({ smallestUnit: 'millisecond' });
const routine = {
id: 1,
@@ -20,33 +30,31 @@ const routine = {
describe('computeRoutineState', () => {
it('is due when never done, even before the due date', () => {
- const now = Date.parse(CREATED) + 3 * DAY_MS;
+ 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(
- new Date(Date.parse(CREATED) + 7 * DAY_MS).toISOString(),
- );
+ expect(state.dueAt).toBe(isoPlusDays(CREATED, 7));
});
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 doneAt = isoPlusDays(CREATED, 5);
+ const now = plusDays(Temporal.Instant.from(doneAt), 2);
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 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 = Date.parse(CREATED) + 22 * DAY_MS;
+ const now = plusDays(created, 22);
const state = computeRoutineState(routine, [], now);
expect(state.status).toBe('veryLate');
expect(state.overdueDays).toBe(15);
@@ -54,27 +62,31 @@ describe('computeRoutineState', () => {
});
it('is due (not late) when overdue by half a frequency', () => {
- const now = Date.parse(CREATED) + 10.5 * DAY_MS;
+ 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 = Date.parse(CREATED) + 14 * DAY_MS;
+ 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 = Date.parse(CREATED) + 7 * DAY_MS + 7 * DAY_MS;
+ 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
- const now = Date.parse('2026-09-08T08:00:00.000Z'); // this morning, <24h later
+ const now = Temporal.Instant.from('2026-09-08T08:00:00.000Z'); // this morning, <24h later
const state = computeRoutineState(daily, [{ routineId: 1, doneAt }], now);
expect(state.status).toBe('due');
});
@@ -86,13 +98,13 @@ describe('computeRoutineState', () => {
const today = computeRoutineState(
daily,
completions,
- Date.parse('2026-09-08T20:00:00.000Z'),
+ Temporal.Instant.from('2026-09-08T20:00:00.000Z'),
);
expect(today.status).toBe('due');
const tomorrow = computeRoutineState(
daily,
completions,
- Date.parse('2026-09-09T20:00:00.000Z'),
+ Temporal.Instant.from('2026-09-09T20:00:00.000Z'),
);
expect(tomorrow.status).toBe('late');
});
@@ -100,7 +112,7 @@ describe('computeRoutineState', () => {
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';
- const now = Date.parse('2026-09-08T20:00:00.000Z');
+ const now = Temporal.Instant.from('2026-09-08T20:00:00.000Z');
const state = computeRoutineState(daily, [{ routineId: 1, doneAt }], now);
expect(state.status).toBe('ok');
});
@@ -108,40 +120,47 @@ describe('computeRoutineState', () => {
describe('computeHealthSeries', () => {
it('decays from 100 at creation to 0 two periods overdue when never done', () => {
- const now = Date.parse(CREATED) + 22 * DAY_MS;
+ const now = plusDays(created, 22);
const series = computeHealthSeries(routine, [], now);
expect(series[0].health).toBe(100);
- const at8d = series.find((p) => p.at === Date.parse(CREATED) + 8 * DAY_MS);
+ const at8d = series.find(
+ (p) => p.at === plusDays(created, 8).epochMilliseconds,
+ );
expect(at8d?.health).toBe(93); // 1/7 of a period overdue
const last = series[series.length - 1];
- expect(last.at).toBe(now);
+ expect(last.at).toBe(now.epochMilliseconds);
expect(last.health).toBe(0); // more than two full periods overdue
});
it('resets to 100 right after a completion, then decays again (sawtooth)', () => {
- const doneAt = Date.parse(CREATED) + 5 * DAY_MS;
- const now = doneAt + 13 * DAY_MS; // 13 days after the completion
+ const doneAt = plusDays(created, 5);
+ const now = plusDays(doneAt, 13); // 13 days after the completion
+ const doneAtIso = doneAt.toString({ smallestUnit: 'millisecond' });
const series = computeHealthSeries(
routine,
- [{ routineId: 1, doneAt: new Date(doneAt).toISOString() }],
+ [{ routineId: 1, doneAt: doneAtIso }],
now,
);
- const rightAfter = series.find((p) => p.at >= doneAt);
+ const rightAfter = series.find((p) => p.at >= doneAt.epochMilliseconds);
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);
+ const at8d = series.find(
+ (p) => p.at === plusDays(doneAt, 8).epochMilliseconds,
+ );
expect(at8d?.health).toBe(93);
});
it('a second completion resets the decay again', () => {
- const first = Date.parse(CREATED) + 3 * DAY_MS;
- const second = Date.parse(CREATED) + 10 * DAY_MS;
- const now = Date.parse(CREATED) + 11 * DAY_MS;
+ const first = plusDays(created, 3);
+ const second = plusDays(created, 10);
+ const now = plusDays(created, 11);
+ const stamp = (at: Temporal.Instant): string =>
+ at.toString({ smallestUnit: 'millisecond' });
const series = computeHealthSeries(
routine,
[
- { routineId: 1, doneAt: new Date(first).toISOString() },
- { routineId: 1, doneAt: new Date(second).toISOString() },
+ { routineId: 1, doneAt: stamp(first) },
+ { routineId: 1, doneAt: stamp(second) },
],
now,
);
@@ -153,28 +172,25 @@ describe('computeHealthSeries', () => {
describe('computeProjectHealthSeries', () => {
it('is empty with no routines', () => {
- expect(computeProjectHealthSeries([], [], Date.parse(CREATED))).toEqual([]);
+ expect(computeProjectHealthSeries([], [], created)).toEqual([]);
});
it('averages per-routine health and ends at the current score', () => {
const second = { ...routine, id: 2, name: 'read' };
- const doneAt = Date.parse(CREATED) + 5 * DAY_MS;
- const now = Date.parse(CREATED) + 8 * DAY_MS;
+ const doneAt = plusDays(created, 5);
+ const now = plusDays(created, 8);
+ const doneAtIso = doneAt.toString({ smallestUnit: 'millisecond' });
const series = computeProjectHealthSeries(
[routine, second],
- [{ routineId: 2, doneAt: new Date(doneAt).toISOString() }],
+ [{ routineId: 2, doneAt: doneAtIso }],
now,
);
const states = [
computeRoutineState(routine, [], now),
- computeRoutineState(
- second,
- [{ routineId: 2, doneAt: new Date(doneAt).toISOString() }],
- now,
- ),
+ computeRoutineState(second, [{ routineId: 2, doneAt: doneAtIso }], now),
];
const last = series[series.length - 1];
- expect(last.at).toBe(now);
+ expect(last.at).toBe(now.epochMilliseconds);
expect(last.health).toBe(computeHealthScore(states));
});
@@ -183,18 +199,14 @@ describe('computeProjectHealthSeries', () => {
...routine,
id: 2,
name: 'latecomer',
- createdAt: new Date(Date.parse(CREATED) + 10 * DAY_MS).toISOString(),
+ createdAt: isoPlusDays(CREATED, 10),
};
- const now = Date.parse(CREATED) + 11 * DAY_MS;
+ const now = plusDays(created, 11);
const series = computeProjectHealthSeries([routine, late], [], now);
const beforeLate = series.find(
- (p) => p.at === Date.parse(CREATED) + 5 * DAY_MS,
- );
- const single = computeHealthSeries(
- routine,
- [],
- Date.parse(CREATED) + 5 * DAY_MS,
+ (p) => p.at === plusDays(created, 5).epochMilliseconds,
);
+ const single = computeHealthSeries(routine, [], plusDays(created, 5));
expect(beforeLate?.health).toBe(single[single.length - 1].health);
});
});
@@ -226,13 +238,9 @@ describe('computeHealthScore', () => {
});
it('averages routine healths', () => {
- const now = Date.parse(CREATED) + 8 * DAY_MS;
+ const now = plusDays(created, 8);
const a = computeRoutineState(routine, [], now); // overdue 1 day
- const b = computeRoutineState(
- routine,
- [],
- Date.parse(CREATED) + 3 * DAY_MS,
- );
+ const b = computeRoutineState(routine, [], plusDays(created, 3));
expect(computeHealthScore([a, b])).toBe(
Math.round((a.health + b.health) / 2),
);
diff --git a/src/lib/shared/routine-metrics.ts b/src/lib/shared/routine-metrics.ts
index cffeb85..953a025 100644
--- a/src/lib/shared/routine-metrics.ts
+++ b/src/lib/shared/routine-metrics.ts
@@ -1,14 +1,53 @@
// Pure routine metrics, shared by server services and UI. No db access.
-// All timestamps are ISO strings; all math is done in ms then converted to days.
-// Due dates anchor on UTC calendar days, not exact 24h intervals: a daily
+// All timestamps are ISO strings; calendar math uses Temporal in UTC: due
+// dates anchor on UTC calendar days, not exact 24h intervals: a daily
// routine done yesterday is due today, whatever the time of day.
-const DAY_MS = 24 * 60 * 60 * 1000;
+const UTC = 'UTC';
-/** Midnight UTC starting the calendar day containing ms. */
-function startOfUtcDay(ms: number): number {
- const d = new Date(ms);
- return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
+/** Parse an ISO stamp, falling back when it is not a valid instant. */
+function parseInstant(
+ value: string,
+ fallback: Temporal.Instant,
+): Temporal.Instant {
+ try {
+ return Temporal.Instant.from(value);
+ } catch {
+ return fallback;
+ }
+}
+
+/** Millisecond-precision ISO stamp, byte-identical to Date.toISOString(). */
+function iso(instant: Temporal.Instant): string {
+ return instant.toString({ smallestUnit: 'millisecond' });
+}
+
+/** Whole days as an exact millisecond count. */
+function periodMs(days: number): number {
+ return Temporal.Duration.from({ days }).total({ unit: 'millisecond' });
+}
+
+function maxInstant(instants: Temporal.Instant[]): Temporal.Instant | null {
+ if (instants.length === 0) return null;
+ return instants.reduce((a, b) =>
+ Temporal.Instant.compare(a, b) >= 0 ? a : b,
+ );
+}
+
+function parseCompletions(
+ routineId: number,
+ completions: CompletionInput[],
+): Temporal.Instant[] {
+ const mine: Temporal.Instant[] = [];
+ for (const c of completions) {
+ if (c.routineId !== routineId) continue;
+ try {
+ mine.push(Temporal.Instant.from(c.doneAt));
+ } catch {
+ // Skip corrupt stamps instead of poisoning the anchor.
+ }
+ }
+ return mine.sort(Temporal.Instant.compare);
}
export type RoutineStatus = 'ok' | 'due' | 'late' | 'veryLate';
@@ -46,28 +85,22 @@ export interface RoutineState {
export function computeRoutineState(
routine: RoutineInput,
completions: CompletionInput[],
- now: number,
+ now: Temporal.Instant,
): RoutineState {
- const mine = completions
- .filter((c) => c.routineId === routine.id)
- .map((c) => Date.parse(c.doneAt));
- const lastDoneMs = mine.length > 0 ? Math.max(...mine) : null;
- const createdMs = Date.parse(routine.createdAt);
- const referenceMs =
- lastDoneMs ?? (Number.isFinite(createdMs) ? createdMs : now);
- const periodMs = routine.frequencyDays * DAY_MS;
+ const lastDone = maxInstant(parseCompletions(routine.id, completions));
+ const reference = lastDone ?? parseInstant(routine.createdAt, now);
+ 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.
- const dueMs = startOfUtcDay(referenceMs) + periodMs;
- const rawRatio = (now - dueMs) / periodMs;
+ const due = reference
+ .toZonedDateTimeISO(UTC)
+ .startOfDay()
+ .add({ days: routine.frequencyDays })
+ .toInstant();
+ const rawRatio = (now.epochMilliseconds - due.epochMilliseconds) / period;
const overdueRatio = Math.max(0, rawRatio);
- // 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
+ rawRatio < 0 && lastDone !== null
? 'ok'
: overdueRatio < 1
? 'due'
@@ -80,8 +113,8 @@ export function computeRoutineState(
version: routine.version,
name: routine.name,
frequencyDays: routine.frequencyDays,
- lastDoneAt: lastDoneMs !== null ? new Date(lastDoneMs).toISOString() : null,
- dueAt: new Date(dueMs).toISOString(),
+ lastDoneAt: lastDone !== null ? iso(lastDone) : null,
+ dueAt: iso(due),
overdueDays: Math.floor(overdueRatio * routine.frequencyDays),
overdueRatio,
status,
@@ -104,9 +137,18 @@ export interface HealthPoint {
}
/** Health at instant t given the reference anchor (creation or last completion). */
-function healthAtPoint(refMs: number, periodMs: number, t: number): number {
- const dueMs = startOfUtcDay(refMs) + periodMs;
- const rawRatio = (t - dueMs) / periodMs;
+function healthAtPoint(
+ reference: Temporal.Instant,
+ frequencyDays: number,
+ t: Temporal.Instant,
+): number {
+ const due = reference
+ .toZonedDateTimeISO(UTC)
+ .startOfDay()
+ .add({ days: frequencyDays })
+ .toInstant();
+ const rawRatio =
+ (t.epochMilliseconds - due.epochMilliseconds) / periodMs(frequencyDays);
return Math.max(0, Math.round(100 - Math.max(0, rawRatio) * 50));
}
@@ -120,43 +162,41 @@ function healthAtPoint(refMs: number, periodMs: number, t: number): number {
export function computeHealthSeries(
routine: RoutineInput,
completions: CompletionInput[],
- now: number,
+ now: Temporal.Instant,
): HealthPoint[] {
- const createdMs = Date.parse(routine.createdAt);
- const startMs = Number.isFinite(createdMs) ? createdMs : now;
- const periodMs = routine.frequencyDays * DAY_MS;
-
- const completionMs = completions
- .filter((c) => c.routineId === routine.id)
- .map((c) => Date.parse(c.doneAt))
- .sort((a, b) => a - b);
+ const start = parseInstant(routine.createdAt, now);
+ const doneAt = parseCompletions(routine.id, completions);
const points: HealthPoint[] = [];
- if (now <= startMs) {
- return [{ at: now, health: 100 }];
+ if (Temporal.Instant.compare(now, start) <= 0) {
+ return [{ at: now.epochMilliseconds, health: 100 }];
}
// Reference anchor: latest completion at-or-before the sampled instant.
- let refMs = startMs;
+ let reference = start;
let nextCompletion = 0;
- const sampleAt = (t: number): number => {
+ const sampleAt = (t: Temporal.Instant): number => {
while (
- nextCompletion < completionMs.length &&
- completionMs[nextCompletion] <= t
+ nextCompletion < doneAt.length &&
+ Temporal.Instant.compare(doneAt[nextCompletion], t) <= 0
) {
- refMs = completionMs[nextCompletion];
+ reference = doneAt[nextCompletion];
nextCompletion++;
}
- return healthAtPoint(refMs, periodMs, t);
+ return healthAtPoint(reference, routine.frequencyDays, t);
};
- for (let t = startMs; t <= now; t += DAY_MS) {
- points.push({ at: t, health: sampleAt(t) });
+ for (
+ let t = start;
+ Temporal.Instant.compare(t, now) <= 0;
+ t = t.toZonedDateTimeISO(UTC).add({ days: 1 }).toInstant()
+ ) {
+ points.push({ at: t.epochMilliseconds, health: sampleAt(t) });
}
// Final unaligned sample so the curve ends at the present instant.
- if (points[points.length - 1].at < now) {
- points.push({ at: now, health: sampleAt(now) });
+ if (points[points.length - 1].at < now.epochMilliseconds) {
+ points.push({ at: now.epochMilliseconds, health: sampleAt(now) });
}
return points;
}
@@ -170,54 +210,71 @@ export function computeHealthSeries(
export function computeProjectHealthSeries(
routines: RoutineInput[],
completions: CompletionInput[],
- now: number,
+ now: Temporal.Instant,
): HealthPoint[] {
if (routines.length === 0) return [];
const ids = new Set(routines.map((r) => r.id));
- const completionsByRoutine = new Map<number, number[]>();
+ const completionsByRoutine = new Map<number, Temporal.Instant[]>();
for (const c of completions) {
if (!ids.has(c.routineId)) continue;
- const t = Date.parse(c.doneAt);
- if (!Number.isFinite(t)) continue;
- completionsByRoutine.set(c.routineId, [
- ...(completionsByRoutine.get(c.routineId) ?? []),
- t,
- ]);
+ try {
+ const t = Temporal.Instant.from(c.doneAt);
+ completionsByRoutine.set(c.routineId, [
+ ...(completionsByRoutine.get(c.routineId) ?? []),
+ t,
+ ]);
+ } catch {
+ // Skip corrupt stamps instead of poisoning the anchor.
+ }
}
for (const times of completionsByRoutine.values())
- times.sort((a, b) => a - b);
+ times.sort(Temporal.Instant.compare);
- const createdOrNow = (r: RoutineInput): number => {
- const t = Date.parse(r.createdAt);
- return Number.isFinite(t) ? t : now;
- };
+ const createdOrNow = (r: RoutineInput): Temporal.Instant =>
+ parseInstant(r.createdAt, now);
// Mean per-routine health across the routines alive at t.
- const sampleAt = (t: number): number | null => {
- const alive = routines.filter((r) => Date.parse(r.createdAt) <= t);
+ const sampleAt = (t: Temporal.Instant): number | null => {
+ const alive = routines.filter((r) => {
+ try {
+ return (
+ Temporal.Instant.compare(Temporal.Instant.from(r.createdAt), t) <= 0
+ );
+ } catch {
+ return false;
+ }
+ });
if (alive.length === 0) return null;
const total = alive.reduce((sum, r) => {
const doneAt = (completionsByRoutine.get(r.id) ?? []).filter(
- (d) => d <= t,
+ (d) => Temporal.Instant.compare(d, t) <= 0,
);
- const anchor = doneAt.length > 0 ? Math.max(...doneAt) : createdOrNow(r);
- return sum + healthAtPoint(anchor, r.frequencyDays * DAY_MS, t);
+ const anchor =
+ doneAt.length > 0 ? (maxInstant(doneAt) ?? now) : createdOrNow(r);
+ return sum + healthAtPoint(anchor, r.frequencyDays, t);
}, 0);
return Math.round(total / alive.length);
};
- const startMs = Math.min(...routines.map(createdOrNow));
- if (now <= startMs) return [{ at: now, health: 100 }];
+ const start = routines
+ .map(createdOrNow)
+ .reduce((a, b) => (Temporal.Instant.compare(a, b) <= 0 ? a : b));
+ if (Temporal.Instant.compare(now, start) <= 0)
+ return [{ at: now.epochMilliseconds, health: 100 }];
const points: HealthPoint[] = [];
- for (let t = startMs; t <= now; t += DAY_MS) {
+ for (
+ let t = start;
+ Temporal.Instant.compare(t, now) <= 0;
+ t = t.toZonedDateTimeISO(UTC).add({ days: 1 }).toInstant()
+ ) {
const health = sampleAt(t);
- if (health !== null) points.push({ at: t, health });
+ if (health !== null) points.push({ at: t.epochMilliseconds, health });
}
- if (points.length === 0) return [{ at: now, health: 100 }];
+ if (points.length === 0) return [{ at: now.epochMilliseconds, health: 100 }];
// Final unaligned sample so the curve ends at the present instant.
- if (points[points.length - 1].at < now) {
- points.push({ at: now, health: sampleAt(now) ?? 100 });
+ if (points[points.length - 1].at < now.epochMilliseconds) {
+ points.push({ at: now.epochMilliseconds, health: sampleAt(now) ?? 100 });
}
return points;
}
diff --git a/src/lib/shared/temporal.ts b/src/lib/shared/temporal.ts
new file mode 100644
index 0000000..7097ba3
--- /dev/null
+++ b/src/lib/shared/temporal.ts
@@ -0,0 +1,27 @@
+// Shared Temporal display helpers. Storage and logic stay in ISO instants;
+// these format one for the viewer in their own timezone.
+
+/** Format an ISO instant in the viewer's timezone. */
+export function formatInstant(
+ iso: string,
+ options: Intl.DateTimeFormatOptions,
+): string {
+ return Temporal.Instant.from(iso)
+ .toZonedDateTimeISO(Temporal.Now.timeZoneId())
+ .toLocaleString('fr-FR', options);
+}
+
+/** Short day-month stamp ("8 sept."). */
+
+export function formatDayMonth(iso: string): string {
+ return formatInstant(iso, { day: 'numeric', month: 'short' });
+}
+
+/** Day-month-year stamp ("8 sept. 2026"). */
+export function formatDayMonthYear(iso: string): string {
+ return formatInstant(iso, {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ });
+}