aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/lib/shared/routine-metrics.test.ts64
-rw-r--r--src/lib/shared/routine-metrics.ts27
-rw-r--r--src/routes/projects/[id]/+page.svelte11
-rw-r--r--src/routes/projects/[projectId]/routine/[routineId]/+page.svelte11
-rw-r--r--src/temporal-ambient.d.ts3
5 files changed, 104 insertions, 12 deletions
diff --git a/src/lib/shared/routine-metrics.test.ts b/src/lib/shared/routine-metrics.test.ts
index 88d0498..7f4d656 100644
--- a/src/lib/shared/routine-metrics.test.ts
+++ b/src/lib/shared/routine-metrics.test.ts
@@ -4,6 +4,7 @@ import {
computeHealthScore,
computeHealthSeries,
computeProjectHealthSeries,
+ formatDoneAge,
type HealthPoint,
} from './routine-metrics';
const CREATED = '2026-09-01T00:00:00.000Z';
@@ -240,6 +241,69 @@ describe('computeRoutineState (quota)', () => {
});
});
+describe('daysSinceDone', () => {
+ it('is null when the routine was never done', () => {
+ const state = computeRoutineState(routine, [], plusDays(created, 3));
+ expect(state.daysSinceDone).toBeNull();
+ });
+
+ it('counts whole UTC calendar days since the last completion', () => {
+ const state = computeRoutineState(
+ routine,
+ [{ routineId: 1, doneAt: '2026-09-07T20:00:00.000Z' }],
+ Temporal.Instant.from('2026-09-10T08:00:00.000Z'),
+ );
+ expect(state.daysSinceDone).toBe(3); // not 2: calendar days, not 24h chunks
+ });
+
+ it('is 0 for a completion earlier the same calendar day', () => {
+ const state = computeRoutineState(
+ routine,
+ [{ routineId: 1, doneAt: '2026-09-10T08:00:00.000Z' }],
+ Temporal.Instant.from('2026-09-10T20:00:00.000Z'),
+ );
+ expect(state.daysSinceDone).toBe(0);
+ });
+
+ it('clamps a future-dated completion to 0', () => {
+ const state = computeRoutineState(
+ routine,
+ [{ routineId: 1, doneAt: '2026-09-12T08:00:00.000Z' }],
+ Temporal.Instant.from('2026-09-10T20:00:00.000Z'),
+ );
+ expect(state.daysSinceDone).toBe(0);
+ });
+});
+
+describe('formatDoneAge', () => {
+ it('shows the lateness once a day past due, even when never done', () => {
+ expect(formatDoneAge({ daysSinceDone: null, overdueDays: 3 })).toBe(
+ '3 jours de retard',
+ );
+ expect(formatDoneAge({ daysSinceDone: 10, overdueDays: 1 })).toBe(
+ '1 jour de retard',
+ );
+ expect(formatDoneAge({ daysSinceDone: 10, overdueDays: 3 })).toBe(
+ '3 jours de retard',
+ );
+ });
+
+ it('shows the time since done while the deadline is still ahead', () => {
+ expect(formatDoneAge({ daysSinceDone: null, overdueDays: 0 })).toBe(
+ 'jamais fait',
+ );
+ expect(formatDoneAge({ daysSinceDone: 0, overdueDays: 0 })).toBe(
+ "fait aujourd'hui",
+ );
+ expect(formatDoneAge({ daysSinceDone: 1, overdueDays: 0 })).toBe(
+ 'fait hier',
+ );
+ expect(formatDoneAge({ daysSinceDone: 5, overdueDays: 0 })).toBe(
+ 'fait il y a 5 jours',
+ );
+ });
+});
+
describe('computeHealthSeries', () => {
it('decays from 100 at creation to 0 two periods overdue when never done', () => {
const now = plusDays(created, 22);
diff --git a/src/lib/shared/routine-metrics.ts b/src/lib/shared/routine-metrics.ts
index d3c1470..4417539 100644
--- a/src/lib/shared/routine-metrics.ts
+++ b/src/lib/shared/routine-metrics.ts
@@ -38,6 +38,15 @@ function maxInstant(instants: Temporal.Instant[]): Temporal.Instant | null {
);
}
+/** Whole UTC-calendar days from an instant to now, floored at 0. */
+function daysAgo(then: Temporal.Instant, now: Temporal.Instant): number {
+ const days = then
+ .toZonedDateTimeISO(UTC)
+ .toPlainDate()
+ .until(now.toZonedDateTimeISO(UTC).toPlainDate()).days;
+ return Math.max(0, days);
+}
+
function parseCompletions(
routineId: number,
completions: CompletionInput[],
@@ -75,6 +84,8 @@ export interface RoutineState {
/** Spec text, as stored. */
frequency: string;
lastDoneAt: string | null;
+ /** Whole UTC-calendar days since lastDoneAt (null when never done). */
+ daysSinceDone: number | null;
/** When the pending obligation must be done (window end for quotas). */
dueAt: string;
/** Days past dueAt (0 while the deadline is still ahead). */
@@ -131,6 +142,7 @@ export function computeRoutineState(
name: routine.name,
frequency: routine.frequency,
lastDoneAt: lastDone !== null ? iso(lastDone) : null,
+ daysSinceDone: lastDone !== null ? daysAgo(lastDone, now) : null,
dueAt: iso(due),
overdueDays: Math.floor(overdueRatio * days),
overdueRatio,
@@ -223,6 +235,7 @@ function quotaState(
name: routine.name,
frequency: routine.frequency,
lastDoneAt: lastDone !== null ? iso(lastDone) : null,
+ daysSinceDone: lastDone !== null ? daysAgo(lastDone, now) : null,
dueAt: iso(due),
overdueDays: Math.max(
0,
@@ -396,3 +409,17 @@ export const STATUS_LABELS: Record<RoutineStatus, string> = {
late: 'En retard',
veryLate: 'Très en retard',
};
+
+/** Meta stamp next to the frequency: lateness past due, else time since done. */
+export function formatDoneAge(
+ state: Pick<RoutineState, 'daysSinceDone' | 'overdueDays'>,
+): string {
+ if (state.overdueDays > 0) {
+ const n = state.overdueDays;
+ return `${n} jour${n > 1 ? 's' : ''} de retard`;
+ }
+ if (state.daysSinceDone === null) return 'jamais fait';
+ if (state.daysSinceDone === 0) return "fait aujourd'hui";
+ if (state.daysSinceDone === 1) return 'fait hier';
+ return `fait il y a ${state.daysSinceDone} jours`;
+}
diff --git a/src/routes/projects/[id]/+page.svelte b/src/routes/projects/[id]/+page.svelte
index f93e018..0f3e857 100644
--- a/src/routes/projects/[id]/+page.svelte
+++ b/src/routes/projects/[id]/+page.svelte
@@ -6,7 +6,10 @@
import StatusBadge from '$lib/components/StatusBadge.svelte';
import Button from '$lib/components/Button.svelte';
import Row from '$lib/components/Row.svelte';
- import type { RoutineState } from '$lib/shared/routine-metrics';
+ import {
+ formatDoneAge,
+ type RoutineState,
+ } from '$lib/shared/routine-metrics';
import { Frequency } from '$lib/shared/frequency';
import SectionTitle from '$lib/components/SectionTitle.svelte';
import { formatDayMonth } from '$lib/shared/temporal';
@@ -134,11 +137,7 @@
>
<span class="routine-meta">
{Frequency.parse(routine.frequency).format()}
- {#if routine.lastDoneAt}
- · fait le {formatDayMonth(routine.lastDoneAt)}
- {:else}
- · jamais fait
- {/if}
+ · {formatDoneAge(routine)}
</span>
</div>
</Row>
diff --git a/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte b/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte
index bd74b90..2dc5427 100644
--- a/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte
+++ b/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte
@@ -5,7 +5,10 @@
import StatusBadge from '$lib/components/StatusBadge.svelte';
import Button from '$lib/components/Button.svelte';
import Row from '$lib/components/Row.svelte';
- import { computeHealthSeries } from '$lib/shared/routine-metrics';
+ import {
+ computeHealthSeries,
+ formatDoneAge,
+ } from '$lib/shared/routine-metrics';
import { Frequency } from '$lib/shared/frequency';
import { formatDayMonth, formatDayMonthYear } from '$lib/shared/temporal';
import SectionTitle from '$lib/components/SectionTitle.svelte';
@@ -149,11 +152,7 @@
/>
<span class="chart-meta">
{Frequency.parse(data.routine.frequency).format()}
- {#if data.state.lastDoneAt}
- · fait le {formatDayMonth(data.state.lastDoneAt)}
- {:else}
- · jamais fait
- {/if}
+ · {formatDoneAge(data.state)}
</span>
</div>
{/if}
diff --git a/src/temporal-ambient.d.ts b/src/temporal-ambient.d.ts
index c5ec34f..38e69d4 100644
--- a/src/temporal-ambient.d.ts
+++ b/src/temporal-ambient.d.ts
@@ -10,6 +10,7 @@ declare global {
};
interface Duration {
+ readonly days: number;
total(options: { unit: 'millisecond' | 'day' }): number;
}
namespace Duration {
@@ -47,6 +48,8 @@ declare global {
readonly dayOfWeek: number;
add(durationLike: DurationLike): PlainDate;
subtract(durationLike: DurationLike): PlainDate;
+ /** Difference to another date; largest unit defaults to days. */
+ until(other: PlainDate): Duration;
toZonedDateTime(timeZone: TimeZoneId): ZonedDateTime;
toString(): string;
}