diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-30 22:13:07 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-30 22:13:07 +0200 |
| commit | a725d4c7d28fee701b6997cc46cffddefbcd26bf (patch) | |
| tree | 1e0899dc9fa67f8c74d7e4b7023881959d571d4f /src/lib/shared | |
| parent | a17b24b8e95bf85a8522fc0c2cbbdb8da31bf018 (diff) | |
Allow granular routine frequencies like 5/2w
Diffstat (limited to 'src/lib/shared')
| -rw-r--r-- | src/lib/shared/frequency.test.ts | 125 | ||||
| -rw-r--r-- | src/lib/shared/frequency.ts | 114 | ||||
| -rw-r--r-- | src/lib/shared/routine-metrics.test.ts | 231 | ||||
| -rw-r--r-- | src/lib/shared/routine-metrics.ts | 285 |
4 files changed, 637 insertions, 118 deletions
diff --git a/src/lib/shared/frequency.test.ts b/src/lib/shared/frequency.test.ts new file mode 100644 index 0000000..e33dfd7 --- /dev/null +++ b/src/lib/shared/frequency.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from 'vitest'; +import { Frequency } from './frequency'; + +// Branding is compile-time: construction and forgery must not type-check. +// Checked by `npm run check`, inert at runtime. +function _assertUnforgeable(): void { + // @ts-expect-error — Frequency's constructor is private + new Frequency('5/2w'); + // @ts-expect-error — the private brand makes object literals non-assignable + const forged: Frequency = { count: 5, length: 2, unit: 'w' }; + void forged; +} +void _assertUnforgeable; + +describe('Frequency grammar', () => { + it('accepts interval specs', () => { + for (const spec of ['1d', '2w', '3m', '1y', '7d', '07d', '180d']) { + expect(Frequency.safeParse(spec)?.toSpec()).toBe(spec); + } + }); + + it('accepts quota specs with an explicit period length', () => { + for (const spec of ['5/2w', '5/1w', '1/1d', '2/3m', '1/1y', '5/14d']) { + expect(Frequency.safeParse(spec)?.toSpec()).toBe(spec); + } + }); + + it('normalizes case and surrounding whitespace only', () => { + expect(Frequency.safeParse(' 5/2W ')?.toSpec()).toBe('5/2w'); + expect(Frequency.safeParse('2W')?.toSpec()).toBe('2w'); + expect(Frequency.safeParse('07D')?.toSpec()).toBe('07d'); + }); + + it('rejects invalid specs', () => { + const invalid = [ + '', + '2', + 'w', + '2x', + '-1d', + '+2w', + '1.5d', + '0d', + '00d', + '5/w', + '5/14', + '5/2', + '2w/5', + '0/2w', + '5/0w', + '5 /2w', + '1/2/3w', + '2w', + ]; + for (const spec of invalid) { + expect(Frequency.safeParse(spec), spec).toBeNull(); + expect(() => Frequency.parse(spec), spec).toThrow(); + } + }); +}); + +describe('spec round-trip', () => { + it('keeps the spec exactly as written', () => { + for (const spec of ['5/2w', '2w', '07d', '5/02w', '1/1y']) { + expect(Frequency.parse(spec).toSpec()).toBe(spec); + } + }); +}); + +describe('parsed fields', () => { + it('extracts interval parts', () => { + const freq = Frequency.parse('2w'); + expect(freq.count).toBeNull(); + expect(freq.length).toBe(2); + expect(freq.unit).toBe('w'); + }); + + it('extracts quota parts', () => { + const freq = Frequency.parse('5/2w'); + expect(freq.count).toBe(5); + expect(freq.length).toBe(2); + expect(freq.unit).toBe('w'); + }); +}); + +describe('branding', () => { + it('returns branded instances', () => { + expect(Frequency.parse('5/2w')).toBeInstanceOf(Frequency); + expect(Frequency.safeParse('2w')).toBeInstanceOf(Frequency); + }); +}); + +describe('format', () => { + it('keeps day-based interval labels (legacy display)', () => { + expect(Frequency.parse('1d').format()).toBe('chaque jour'); + expect(Frequency.parse('7d').format()).toBe('chaque semaine'); + expect(Frequency.parse('1w').format()).toBe('chaque semaine'); + expect(Frequency.parse('30d').format()).toBe('chaque mois'); + expect(Frequency.parse('365d').format()).toBe('chaque année'); + expect(Frequency.parse('2w').format()).toBe('tous les 14 jours'); + expect(Frequency.parse('14d').format()).toBe('tous les 14 jours'); + expect(Frequency.parse('180d').format()).toBe('tous les 180 jours'); + }); + + it('labels quotas as completions per period', () => { + expect(Frequency.parse('5/2w').format()).toBe('5 fois par 2 semaines'); + expect(Frequency.parse('5/1w').format()).toBe('5 fois par semaine'); + expect(Frequency.parse('3/1d').format()).toBe('3 fois par jour'); + expect(Frequency.parse('2/3m').format()).toBe('2 fois par 3 mois'); + expect(Frequency.parse('1/1y').format()).toBe('1 fois par an'); + expect(Frequency.parse('4/2y').format()).toBe('4 fois par 2 ans'); + expect(Frequency.parse('5/14d').format()).toBe('5 fois par 14 jours'); + }); +}); + +describe('periodDays', () => { + it('extracts day counts for compute only', () => { + expect(Frequency.parse('1d').periodDays()).toBe(1); + expect(Frequency.parse('2w').periodDays()).toBe(14); + expect(Frequency.parse('5/2w').periodDays()).toBe(14); + expect(Frequency.parse('5/1m').periodDays()).toBe(30); + expect(Frequency.parse('2/3w').periodDays()).toBe(21); + expect(Frequency.parse('1y').periodDays()).toBe(365); + }); +}); diff --git a/src/lib/shared/frequency.ts b/src/lib/shared/frequency.ts new file mode 100644 index 0000000..0a82e41 --- /dev/null +++ b/src/lib/shared/frequency.ts @@ -0,0 +1,114 @@ +import * as v from 'valibot'; + +export type FrequencyUnit = 'd' | 'w' | 'm' | 'y'; + +/** Days per unit — the same approximation the interval parser has always used. */ +const UNIT_DAYS: Record<FrequencyUnit, number> = { d: 1, w: 7, m: 30, y: 365 }; + +const UNIT_LABELS: Record<FrequencyUnit, [singular: string, plural: string]> = { + d: ['jour', 'jours'], + w: ['semaine', 'semaines'], + m: ['mois', 'mois'], + y: ['an', 'ans'], +}; + +/** Day-based interval labels, identical to the legacy display. */ +const INTERVAL_LABELS: Record<number, string> = { + 1: 'chaque jour', + 7: 'chaque semaine', + 30: 'chaque mois', + 365: 'chaque année', +}; + +/** `2w` = every 2 weeks; `5/2w` = 5 completions per 2-week window. */ +const SPEC_RE = /^(?:(\d+)\/)?(\d+)([dwmy])$/; + +interface Parts { + /** Quota completions per window; null for interval specs. */ + count: number | null; + length: number; + unit: FrequencyUnit; +} + +function partsOf(spec: string): Parts | null { + const m = SPEC_RE.exec(spec); + if (!m) return null; + const rawCount: string | undefined = m[1]; + const length = Number(m[2]); + if (length < 1) return null; + if (rawCount !== undefined && Number(rawCount) < 1) return null; + return { + count: rawCount === undefined ? null : Number(rawCount), + length, + unit: m[3] as FrequencyUnit, + }; +} + +const SpecSchema = v.pipe( + v.string(), + v.trim(), + v.toLowerCase(), + v.check((spec) => partsOf(spec) !== null), +); + +/** + * Branded frequency value object: parsed values only come from `parse`/`safeParse` + * and are nominally distinct from raw spec strings. The spec text is the value; + * numeric extraction happens here for compute and display only. + */ +export class Frequency { + declare private readonly __brand: 'Frequency'; + + /** Quota completions per window; null for interval specs. */ + readonly count: number | null; + readonly length: number; + readonly unit: FrequencyUnit; + /** Normalized spec exactly as written (trim + lowercase only). */ + private readonly spec: string; + + private constructor(spec: string, parts: Parts) { + this.spec = spec; + this.count = parts.count; + this.length = parts.length; + this.unit = parts.unit; + } + + /** Parses a spec like `2w` or `5/2w`; throws when invalid. */ + static parse(input: string): Frequency { + const frequency = Frequency.safeParse(input); + if (frequency === null) { + throw new Error(`Invalid frequency spec: ${input}`); + } + return frequency; + } + + /** Parses a spec; null when invalid (live form validation). */ + static safeParse(input: string): Frequency | null { + const result = v.safeParse(SpecSchema, input); + if (!result.success) return null; + const parts = partsOf(result.output); + if (parts === null) return null; + return new Frequency(result.output, parts); + } + + /** Day count of one interval period / quota window. */ + periodDays(): number { + return this.length * UNIT_DAYS[this.unit]; + } + + /** The spec text, exactly as written. */ + toSpec(): string { + return this.spec; + } + + /** French label; interval labels stay day-based (legacy display). */ + format(): string { + const days = this.periodDays(); + if (this.count === null) { + return INTERVAL_LABELS[days] ?? `tous les ${days} jours`; + } + const [one, many] = UNIT_LABELS[this.unit]; + const period = this.length > 1 ? `${this.length} ${many}` : one; + return `${this.count} fois par ${period}`; + } +} diff --git a/src/lib/shared/routine-metrics.test.ts b/src/lib/shared/routine-metrics.test.ts index 5bc0a78..88d0498 100644 --- a/src/lib/shared/routine-metrics.test.ts +++ b/src/lib/shared/routine-metrics.test.ts @@ -4,7 +4,7 @@ import { computeHealthScore, computeHealthSeries, computeProjectHealthSeries, - parseIntervalToDays, + type HealthPoint, } from './routine-metrics'; const CREATED = '2026-09-01T00:00:00.000Z'; const created = Temporal.Instant.from(CREATED); @@ -24,10 +24,22 @@ const routine = { id: 1, version: 1, name: 'clean kitchen', - frequencyDays: 7, + frequency: '7d', createdAt: CREATED, }; +const quotaRoutine = { + id: 1, + version: 1, + name: 'hoover', + frequency: '5/2w', + createdAt: '2026-09-07T00:00:00.000Z', // windows: [09-07, 09-21), [09-21, 10-05), … +}; + +/** Completion inputs for routine 1 from ISO stamps. */ +const done = (...stamps: string[]) => + stamps.map((doneAt) => ({ routineId: 1, doneAt })); + describe('computeRoutineState', () => { it('is due immediately at creation when never done (created_at = due)', () => { const now = plusDays(created, 3); @@ -47,14 +59,14 @@ describe('computeRoutineState', () => { }); it('is due the calendar day after completion for a daily routine', () => { - const daily = { ...routine, frequencyDays: 1 }; + const daily = { ...routine, frequency: '1d' }; const doneAt = '2026-09-07T20:00:00.000Z'; // yesterday evening const now = Temporal.Instant.from('2026-09-08T08:00:00.000Z'); // <24h later 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 daily = { ...routine, frequency: '1d' }; const doneAt = '2026-09-07T20:00:00.000Z'; // yesterday evening const completions = [{ routineId: 1, doneAt }]; const today = computeRoutineState( @@ -72,7 +84,7 @@ describe('computeRoutineState', () => { }); it('is ok later the same day it was done for a daily routine', () => { - const daily = { ...routine, frequencyDays: 1 }; + const daily = { ...routine, frequency: '1d' }; const doneAt = '2026-09-08T08:00:00.000Z'; const now = Temporal.Instant.from('2026-09-08T20:00:00.000Z'); const state = computeRoutineState(daily, [{ routineId: 1, doneAt }], now); @@ -80,6 +92,154 @@ describe('computeRoutineState', () => { }); }); +describe('computeRoutineState (quota)', () => { + it('is ok at quota met within the window', () => { + const state = computeRoutineState( + quotaRoutine, + done( + '2026-09-07T08:00:00.000Z', + '2026-09-08T08:00:00.000Z', + '2026-09-09T08:00:00.000Z', + '2026-09-10T08:00:00.000Z', + '2026-09-11T08:00:00.000Z', + ), + Temporal.Instant.from('2026-09-12T12:00:00.000Z'), + ); + expect(state.status).toBe('ok'); + expect(state.health).toBe(100); + expect(state.overdueRatio).toBe(0); + expect(state.overdueDays).toBe(0); + expect(state.frequency).toBe('5/2w'); + expect(state.dueAt).toBe('2026-10-05T00:00:00.000Z'); + }); + + it('is due while the window is open and the quota unmet', () => { + const state = computeRoutineState( + quotaRoutine, + done('2026-09-07T08:00:00.000Z', '2026-09-08T09:00:00.000Z'), + Temporal.Instant.from('2026-09-09T12:00:00.000Z'), + ); + expect(state.status).toBe('due'); + expect(state.health).toBe(91); // 2.5/14 of the window elapsed + expect(state.overdueDays).toBe(0); // deadline 09-21 still ahead + expect(state.dueAt).toBe('2026-09-21T00:00:00.000Z'); + }); + + it('is late as soon as the window closes unmet', () => { + const state = computeRoutineState( + quotaRoutine, + done('2026-09-07T08:00:00.000Z', '2026-09-08T09:00:00.000Z'), + Temporal.Instant.from('2026-09-21T00:05:00.000Z'), + ); + expect(state.status).toBe('late'); + expect(state.health).toBe(50); // one whole window behind + expect(state.overdueDays).toBe(0); + expect(state.dueAt).toBe('2026-09-21T00:00:00.000Z'); + }); + + it('accumulates overdue days while behind', () => { + const state = computeRoutineState( + quotaRoutine, + done('2026-09-07T08:00:00.000Z', '2026-09-08T09:00:00.000Z'), + Temporal.Instant.from('2026-09-22T12:00:00.000Z'), + ); + expect(state.status).toBe('late'); + expect(state.health).toBe(45); // 1.5/14 of the window elapsed behind + expect(state.overdueDays).toBe(1); + expect(state.dueAt).toBe('2026-09-21T00:00:00.000Z'); + }); + + it('is very late after two closed unmet windows', () => { + const state = computeRoutineState( + quotaRoutine, + done('2026-09-07T08:00:00.000Z', '2026-09-08T09:00:00.000Z'), + Temporal.Instant.from('2026-10-05T00:00:05.000Z'), + ); + expect(state.status).toBe('veryLate'); + expect(state.health).toBe(0); + expect(state.overdueDays).toBe(14); + expect(state.dueAt).toBe('2026-09-21T00:00:00.000Z'); + }); + + it('quota met beats a previous window shortfall', () => { + const state = computeRoutineState( + quotaRoutine, + done( + '2026-09-08T08:00:00.000Z', + '2026-09-09T08:00:00.000Z', + '2026-09-10T08:00:00.000Z', + '2026-09-22T08:00:00.000Z', + '2026-09-23T08:00:00.000Z', + '2026-09-24T08:00:00.000Z', + '2026-09-25T08:00:00.000Z', + '2026-09-26T08:00:00.000Z', + ), + Temporal.Instant.from('2026-09-26T12:00:00.000Z'), + ); + expect(state.status).toBe('ok'); + expect(state.health).toBe(100); + expect(state.dueAt).toBe('2026-10-19T00:00:00.000Z'); + }); + + it('counts a completion at the window start in the new window', () => { + const state = computeRoutineState( + quotaRoutine, + done( + '2026-09-08T08:00:00.000Z', + '2026-09-09T08:00:00.000Z', + '2026-09-10T08:00:00.000Z', + '2026-09-11T08:00:00.000Z', + '2026-09-21T00:00:00.000Z', + ), + Temporal.Instant.from('2026-09-21T00:05:00.000Z'), + ); + // 4/5 in window 0 (missed); the fifth landed in window 1. + expect(state.status).toBe('late'); + expect(state.health).toBe(50); + }); + + it('counts a completion at the window end in the old window', () => { + const state = computeRoutineState( + quotaRoutine, + done( + '2026-09-08T08:00:00.000Z', + '2026-09-09T08:00:00.000Z', + '2026-09-10T08:00:00.000Z', + '2026-09-11T08:00:00.000Z', + '2026-09-20T23:59:59.999Z', + ), + Temporal.Instant.from('2026-09-21T00:05:00.000Z'), + ); + // 5/5 in window 0: window 1 starts unmet but not behind. + expect(state.status).toBe('due'); + expect(state.health).toBe(100); + expect(state.dueAt).toBe('2026-10-05T00:00:00.000Z'); + }); + + it('counts backdated completions in the window containing doneAt', () => { + const twoPerFortnight = { ...quotaRoutine, frequency: '2/2w' }; + const state = computeRoutineState( + twoPerFortnight, + done('2026-09-08T08:00:00.000Z', '2026-09-09T09:00:00.000Z'), + Temporal.Instant.from('2026-09-22T12:00:00.000Z'), + ); + // Window 0 is met (2/2) even though both were logged long after; + // window 1 is open and unmet. + expect(state.status).toBe('due'); + expect(state.health).toBe(95); + }); + + it('throws on a spec that cannot parse', () => { + expect(() => + computeRoutineState( + { ...quotaRoutine, frequency: '5/w' }, + [], + Temporal.Instant.from('2026-09-09T12:00:00.000Z'), + ), + ).toThrow('Invalid frequency spec'); + }); +}); + describe('computeHealthSeries', () => { it('decays from 100 at creation to 0 two periods overdue when never done', () => { const now = plusDays(created, 22); @@ -132,6 +292,40 @@ describe('computeHealthSeries', () => { }); }); +describe('computeHealthSeries (quota)', () => { + const healthOn = (series: HealthPoint[], iso: string) => + series.find((p) => p.at === Temporal.Instant.from(iso).epochMilliseconds) + ?.health; + + it('decays within a window, then drops a whole window per miss', () => { + const now = Temporal.Instant.from('2026-10-05T00:00:00.000Z'); + const series = computeHealthSeries(quotaRoutine, [], now); + expect(series[0].health).toBe(100); // window open at creation + expect(healthOn(series, '2026-09-14T00:00:00.000Z')).toBe(75); // half window + expect(healthOn(series, '2026-09-21T00:00:00.000Z')).toBe(50); // 1 window missed + expect(healthOn(series, '2026-09-28T00:00:00.000Z')).toBe(25); + expect(healthOn(series, '2026-10-05T00:00:00.000Z')).toBe(0); // 2 missed + }); + + it('resets to 100 once the quota is met', () => { + const now = Temporal.Instant.from('2026-09-13T00:00:00.000Z'); + const series = computeHealthSeries( + quotaRoutine, + done( + '2026-09-07T00:00:00.000Z', + '2026-09-08T00:00:00.000Z', + '2026-09-09T00:00:00.000Z', + '2026-09-10T00:00:00.000Z', + '2026-09-11T00:00:00.000Z', + ), + now, + ); + expect(healthOn(series, '2026-09-10T00:00:00.000Z')).toBe(89); // 4/5 + expect(healthOn(series, '2026-09-11T00:00:00.000Z')).toBe(100); // 5/5 + expect(healthOn(series, '2026-09-12T00:00:00.000Z')).toBe(100); // met + }); +}); + describe('computeProjectHealthSeries', () => { it('is empty with no routines', () => { expect(computeProjectHealthSeries([], [], created)).toEqual([]); @@ -171,26 +365,15 @@ describe('computeProjectHealthSeries', () => { const single = computeHealthSeries(routine, [], plusDays(created, 5)); expect(beforeLate?.health).toBe(single[single.length - 1].health); }); -}); - -describe('parseIntervalToDays', () => { - it('converts units to days', () => { - expect(parseIntervalToDays('1d')).toBe(1); - expect(parseIntervalToDays('2w')).toBe(14); - expect(parseIntervalToDays('3m')).toBe(90); - expect(parseIntervalToDays('1y')).toBe(365); - }); - - it('accepts whitespace and uppercase', () => { - expect(parseIntervalToDays(' 2W ')).toBe(14); - }); - it('rejects invalid input', () => { - expect(parseIntervalToDays('2')).toBeNull(); - expect(parseIntervalToDays('w')).toBeNull(); - expect(parseIntervalToDays('2x')).toBeNull(); - expect(parseIntervalToDays('-1d')).toBeNull(); - expect(parseIntervalToDays('')).toBeNull(); + it('averages quota routines with the same formula', () => { + const now = Temporal.Instant.from('2026-09-09T12:00:00.000Z'); + const series = computeProjectHealthSeries([quotaRoutine], [], now); + const states = [computeRoutineState(quotaRoutine, [], now)]; + const last = series[series.length - 1]; + expect(last.at).toBe(now.epochMilliseconds); + expect(last.health).toBe(computeHealthScore(states)); + expect(last.health).toBe(91); }); }); diff --git a/src/lib/shared/routine-metrics.ts b/src/lib/shared/routine-metrics.ts index 4cff240..d3c1470 100644 --- a/src/lib/shared/routine-metrics.ts +++ b/src/lib/shared/routine-metrics.ts @@ -1,17 +1,24 @@ // Pure routine metrics shared by server and UI; ISO timestamps, UTC calendar. +import { Frequency } from './frequency'; + const UTC = 'UTC'; +/** Parse an ISO stamp, null when it is not a valid instant. */ +function rawInstant(value: string): Temporal.Instant | null { + try { + return Temporal.Instant.from(value); + } catch { + return null; + } +} + /** 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; - } + return rawInstant(value) ?? fallback; } /** Millisecond-precision ISO stamp, byte-identical to Date.toISOString(). */ @@ -38,11 +45,9 @@ function parseCompletions( 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. - } + const t = rawInstant(c.doneAt); + // Skip corrupt stamps instead of poisoning the anchor. + if (t !== null) mine.push(t); } return mine.sort(Temporal.Instant.compare); } @@ -53,7 +58,8 @@ export interface RoutineInput { id: number; version: number; name: string; - frequencyDays: number; + /** Spec text ("7d" interval, "5/2w" quota); parsed at compute time. */ + frequency: string; createdAt: string; } @@ -66,13 +72,17 @@ export interface RoutineState { id: number; version: number; name: string; - frequencyDays: number; + /** Spec text, as stored. */ + frequency: string; lastDoneAt: string | null; - /** When the routine should next be done. */ + /** When the pending obligation must be done (window end for quotas). */ dueAt: string; - /** Days past dueAt (0 if not yet due). */ + /** Days past dueAt (0 while the deadline is still ahead). */ overdueDays: number; - /** Time past due, in units of the routine's own frequency (0 if not due). */ + /** + * Overdue severity in units of the routine's own period: interval = time + * past due; quota = trailing missed windows + elapsed window fraction. + */ overdueRatio: number; status: RoutineStatus; /** 100 on schedule, 50 one period overdue, 0 at two periods. */ @@ -84,14 +94,25 @@ export function computeRoutineState( completions: CompletionInput[], now: Temporal.Instant, ): RoutineState { - const lastDone = maxInstant(parseCompletions(routine.id, completions)); + const freq = Frequency.parse(routine.frequency); + const doneAt = parseCompletions(routine.id, completions); + const lastDone = maxInstant(doneAt); + if (freq.count !== null) { + const days = freq.periodDays(); + const origin = parseInstant(routine.createdAt, now) + .toZonedDateTimeISO(UTC) + .startOfDay(); + const q = quotaProgress(freq.count, days, doneAt, now, origin.toInstant()); + return quotaState(routine, freq.count, days, q, now, lastDone, origin); + } + const days = freq.periodDays(); const reference = lastDone ?? parseInstant(routine.createdAt, now); - const period = periodMs(routine.frequencyDays); + const period = periodMs(days); // Calendar-day anchor: the period starts at the reference day's midnight. const due = reference .toZonedDateTimeISO(UTC) .startOfDay() - .add({ days: lastDone === null ? 0 : routine.frequencyDays }) + .add({ days: lastDone === null ? 0 : days }) .toInstant(); const rawRatio = (now.epochMilliseconds - due.epochMilliseconds) / period; const overdueRatio = Math.max(0, rawRatio); @@ -108,10 +129,105 @@ export function computeRoutineState( id: routine.id, version: routine.version, name: routine.name, - frequencyDays: routine.frequencyDays, + frequency: routine.frequency, + lastDoneAt: lastDone !== null ? iso(lastDone) : null, + dueAt: iso(due), + overdueDays: Math.floor(overdueRatio * days), + overdueRatio, + status, + health: Math.max(0, Math.round(100 - overdueRatio * 50)), + }; +} + +interface QuotaProgress { + /** Completions inside the current window (by doneAt; backdating-safe). */ + done: number; + /** Consecutive unmet windows ending just before the current one. */ + missed: number; + /** Elapsed fraction of the current window. */ + elapsed: number; + /** Index of the current window since creation day. */ + window: number; +} + +/** + * Routine-anchored quota windows: window j spans + * [startOfDay(createdAt) + j·days, +days). A completion counts toward the + * window containing its doneAt; completions before creation count toward + * window 0 and future-dated ones are ignored. + */ +function quotaProgress( + quota: number, + days: number, + doneAt: Temporal.Instant[], + t: Temporal.Instant, + origin: Temporal.Instant, +): QuotaProgress { + const period = periodMs(days); + const window = Math.max( + 0, + Math.floor((t.epochMilliseconds - origin.epochMilliseconds) / period), + ); + const counts = new Map<number, number>(); + for (const d of doneAt) { + if (Temporal.Instant.compare(d, t) > 0) continue; + const j = Math.max( + 0, + Math.floor((d.epochMilliseconds - origin.epochMilliseconds) / period), + ); + counts.set(j, (counts.get(j) ?? 0) + 1); + } + let missed = 0; + for (let j = window - 1; j >= 0; j--) { + if ((counts.get(j) ?? 0) >= quota) break; + missed++; + } + return { + done: counts.get(window) ?? 0, + missed, + elapsed: + (t.epochMilliseconds - origin.epochMilliseconds - window * period) / + period, + window, + }; +} + +function quotaState( + routine: RoutineInput, + quota: number, + days: number, + q: QuotaProgress, + now: Temporal.Instant, + lastDone: Temporal.Instant | null, + origin: Temporal.ZonedDateTime, +): RoutineState { + const met = q.done >= quota; + const overdueRatio = met ? 0 : Math.max(0, q.missed + q.elapsed); + const status: RoutineStatus = met + ? 'ok' + : overdueRatio < 1 + ? 'due' + : overdueRatio < 2 + ? 'late' + : 'veryLate'; + // Deadline of the pending obligation: the oldest unmet window's end while + // behind, the current window's end while catching up, the next one when met. + const due = origin + .add({ + days: (met ? q.window + 2 : q.window - q.missed + 1) * days, + }) + .toInstant(); + return { + id: routine.id, + version: routine.version, + name: routine.name, + frequency: routine.frequency, lastDoneAt: lastDone !== null ? iso(lastDone) : null, dueAt: iso(due), - overdueDays: Math.floor(overdueRatio * routine.frequencyDays), + overdueDays: Math.max( + 0, + Math.floor((now.epochMilliseconds - due.epochMilliseconds) / periodMs(1)), + ), overdueRatio, status, health: Math.max(0, Math.round(100 - overdueRatio * 50)), @@ -149,12 +265,37 @@ function healthAtPoint( return Math.max(0, Math.round(100 - Math.max(0, rawRatio) * 50)); } +/** Health at t: interval anchor formula or quota window formula. */ +function healthAt( + freq: Frequency, + doneAt: Temporal.Instant[], + t: Temporal.Instant, + created: Temporal.Instant, +): number { + if (freq.count !== null) { + const origin = created.toZonedDateTimeISO(UTC).startOfDay().toInstant(); + const q = quotaProgress(freq.count, freq.periodDays(), doneAt, t, origin); + const overdueRatio = + q.done >= freq.count ? 0 : Math.max(0, q.missed + q.elapsed); + return Math.max(0, Math.round(100 - overdueRatio * 50)); + } + let reference = created; + let neverDone = true; + for (const d of doneAt) { + if (Temporal.Instant.compare(d, t) > 0) break; + reference = d; + neverDone = false; + } + return healthAtPoint(reference, freq.periodDays(), t, neverDone); +} + /** Daily health samples: 100 at completion, decaying while overdue. */ export function computeHealthSeries( routine: RoutineInput, completions: CompletionInput[], now: Temporal.Instant, ): HealthPoint[] { + const freq = Frequency.parse(routine.frequency); const start = parseInstant(routine.createdAt, now); const doneAt = parseCompletions(routine.id, completions); @@ -163,36 +304,23 @@ export function computeHealthSeries( return [{ at: now.epochMilliseconds, health: 100 }]; } - // Reference anchor: latest completion at-or-before the sampled instant. - let reference = start; - let nextCompletion = 0; - const sampleAt = (t: Temporal.Instant): number => { - while ( - nextCompletion < doneAt.length && - Temporal.Instant.compare(doneAt[nextCompletion], t) <= 0 - ) { - reference = doneAt[nextCompletion]; - nextCompletion++; - } - return healthAtPoint( - reference, - routine.frequencyDays, - t, - nextCompletion === 0, - ); - }; - 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) }); + points.push({ + at: t.epochMilliseconds, + health: healthAt(freq, doneAt, t, start), + }); } // Final unaligned sample so the curve ends at the present instant. if (points[points.length - 1].at < now.epochMilliseconds) { - points.push({ at: now.epochMilliseconds, health: sampleAt(now) }); + points.push({ + at: now.epochMilliseconds, + health: healthAt(freq, doneAt, now, start), + }); } return points; } @@ -208,48 +336,38 @@ export function computeProjectHealthSeries( const completionsByRoutine = new Map<number, Temporal.Instant[]>(); for (const c of completions) { if (!ids.has(c.routineId)) continue; - 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. - } + const t = rawInstant(c.doneAt); + if (t === null) continue; // Skip corrupt stamps instead of poisoning the anchor. + completionsByRoutine.set(c.routineId, [ + ...(completionsByRoutine.get(c.routineId) ?? []), + t, + ]); } for (const times of completionsByRoutine.values()) times.sort(Temporal.Instant.compare); - const createdOrNow = (r: RoutineInput): Temporal.Instant => - parseInstant(r.createdAt, now); + const parsed = routines.map((r) => ({ + born: rawInstant(r.createdAt), + freq: Frequency.parse(r.frequency), + created: parseInstant(r.createdAt, now), + doneAt: completionsByRoutine.get(r.id) ?? [], + })); + // Mean per-routine health across the routines alive at 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; - } - }); + const alive = parsed.filter( + (p) => p.born !== null && Temporal.Instant.compare(p.born, t) <= 0, + ); if (alive.length === 0) return null; - const total = alive.reduce((sum, r) => { - const doneAt = (completionsByRoutine.get(r.id) ?? []).filter( - (d) => Temporal.Instant.compare(d, t) <= 0, - ); - const anchor = - doneAt.length > 0 ? (maxInstant(doneAt) ?? now) : createdOrNow(r); - return ( - sum + healthAtPoint(anchor, r.frequencyDays, t, doneAt.length === 0) - ); - }, 0); + const total = alive.reduce( + (sum, p) => sum + healthAt(p.freq, p.doneAt, t, p.created), + 0, + ); return Math.round(total / alive.length); }; - const start = routines - .map(createdOrNow) + const start = parsed + .map((p) => p.created) .reduce((a, b) => (Temporal.Instant.compare(a, b) <= 0 ? a : b)); if (Temporal.Instant.compare(now, start) <= 0) return [{ at: now.epochMilliseconds, health: 100 }]; @@ -272,27 +390,6 @@ export function computeProjectHealthSeries( return points; } -export function formatFrequency(days: number): string { - if (days === 1) return 'chaque jour'; - if (days === 7) return 'chaque semaine'; - if (days === 30) return 'chaque mois'; - if (days === 365) return 'chaque année'; - return `tous les ${days} jours`; -} - -const UNIT_DAYS: Record<string, number> = { d: 1, w: 7, m: 30, y: 365 }; - -/** Parses "1d | 2w | 3m | 1y" into days; returns null when invalid. */ -export function parseIntervalToDays(input: string): number | null { - const match = input - .trim() - .toLowerCase() - .match(/^(\d+)([dwmy])$/); - if (!match) return null; - const days = Number(match[1]) * UNIT_DAYS[match[2]]; - return days >= 1 ? days : null; -} - export const STATUS_LABELS: Record<RoutineStatus, string> = { ok: 'À jour', due: 'À faire', |
