diff options
| -rw-r--r-- | src/lib/shared/routine-metrics.test.ts | 64 | ||||
| -rw-r--r-- | src/lib/shared/routine-metrics.ts | 27 | ||||
| -rw-r--r-- | src/routes/projects/[id]/+page.svelte | 11 | ||||
| -rw-r--r-- | src/routes/projects/[projectId]/routine/[routineId]/+page.svelte | 11 | ||||
| -rw-r--r-- | src/temporal-ambient.d.ts | 3 |
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; } |
