aboutsummaryrefslogtreecommitdiff
path: root/src/lib
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-09-30 22:13:07 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-09-30 22:13:07 +0200
commita725d4c7d28fee701b6997cc46cffddefbcd26bf (patch)
tree1e0899dc9fa67f8c74d7e4b7023881959d571d4f /src/lib
parenta17b24b8e95bf85a8522fc0c2cbbdb8da31bf018 (diff)
Allow granular routine frequencies like 5/2w
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/server/db/schema.ts2
-rw-r--r--src/lib/server/services/routine-actions.ts15
-rw-r--r--src/lib/server/services/routines.test.ts46
-rw-r--r--src/lib/server/services/routines.ts12
-rw-r--r--src/lib/server/services/validation.ts10
-rw-r--r--src/lib/shared/frequency.test.ts125
-rw-r--r--src/lib/shared/frequency.ts114
-rw-r--r--src/lib/shared/routine-metrics.test.ts231
-rw-r--r--src/lib/shared/routine-metrics.ts285
9 files changed, 677 insertions, 163 deletions
diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts
index a7fcd8f..b6404ce 100644
--- a/src/lib/server/db/schema.ts
+++ b/src/lib/server/db/schema.ts
@@ -50,7 +50,7 @@ export const routines = sqliteTable(
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
- frequencyDays: integer('frequency_days').notNull(),
+ frequency: text('frequency').notNull(),
description: text('description').notNull().default(''),
archived: integer('archived', { mode: 'boolean' }).notNull().default(false),
archivedAt: text('archived_at'),
diff --git a/src/lib/server/services/routine-actions.ts b/src/lib/server/services/routine-actions.ts
index 44ba2b8..1d7f8b5 100644
--- a/src/lib/server/services/routine-actions.ts
+++ b/src/lib/server/services/routine-actions.ts
@@ -1,7 +1,8 @@
import { fail, type ActionFailure } from '@sveltejs/kit';
import { archive, complete, edit, loadCurrent } from './routines';
-import { parseFormString, parseFrequencyDays } from './validation';
+import { parseFormString } from './validation';
import { instantFromDay } from '$lib/shared/temporal';
+import { Frequency } from '$lib/shared/frequency';
/** Shared routine edit/complete actions; rejects missing/archived routines. */
@@ -26,16 +27,20 @@ export async function editRoutineAction(
return fail(400, { error: 'Le nom est requis' });
}
- let frequencyDays: number;
+ let frequency: string;
try {
- frequencyDays = parseFrequencyDays(data.get('frequencyDays'));
+ frequency = Frequency.parse(
+ data.get('frequency')?.toString() ?? '',
+ ).toSpec();
} catch {
return fail(400, { error: 'Fréquence invalide' });
}
// Only touch description when the form sends one; list edits never wipe it.
- const fields: { name: string; frequencyDays: number; description?: string } =
- { name, frequencyDays };
+ const fields: { name: string; frequency: string; description?: string } = {
+ name,
+ frequency,
+ };
const description = data.get('description');
if (description !== null) {
fields.description = description.toString();
diff --git a/src/lib/server/services/routines.test.ts b/src/lib/server/services/routines.test.ts
index eda8824..2ad409a 100644
--- a/src/lib/server/services/routines.test.ts
+++ b/src/lib/server/services/routines.test.ts
@@ -24,46 +24,46 @@ describe('routines service', () => {
});
it('creates a routine with version 1', async () => {
- const r = await add(projectId, 'clean kitchen', 7, db);
+ const r = await add(projectId, 'clean kitchen', '7d', db);
expect(r.id).toBe(1);
expect(r.version).toBe(1);
- expect(r.frequencyDays).toBe(7);
+ expect(r.frequency).toBe('7d');
});
it('edit inserts a new snapshot version and keeps history', async () => {
- const r = await add(projectId, 'clean kitchen', 7, db);
+ const r = await add(projectId, 'clean kitchen', '7d', db);
const updated = await edit(
r.id,
- { name: 'clean kitchen deeply', frequencyDays: 3 },
+ { name: 'clean kitchen deeply', frequency: '3d' },
db,
);
expect(updated.version).toBe(2);
- expect(updated.frequencyDays).toBe(3);
+ expect(updated.frequency).toBe('3d');
const versions = await loadAllVersions(r.id, db);
expect(versions).toHaveLength(2);
- expect(versions[0].frequencyDays).toBe(7);
- expect(versions[1].frequencyDays).toBe(3);
+ expect(versions[0].frequency).toBe('7d');
+ expect(versions[1].frequency).toBe('3d');
});
it('new routines start with an empty description', async () => {
- const r = await add(projectId, 'clean kitchen', 7, db);
+ const r = await add(projectId, 'clean kitchen', '7d', db);
expect(r.description).toBe('');
});
it('edit is partial: name/frequency edits keep the description and vice-versa', async () => {
- const r = await add(projectId, 'clean kitchen', 7, db);
+ const r = await add(projectId, 'clean kitchen', '7d', db);
const described = await edit(r.id, { description: 'wax on, wax off' }, db);
expect(described.name).toBe('clean kitchen');
- expect(described.frequencyDays).toBe(7);
+ expect(described.frequency).toBe('7d');
expect(described.description).toBe('wax on, wax off');
const renamed = await edit(r.id, { name: 'clean the whole kitchen' }, db);
expect(renamed.version).toBe(3);
expect(renamed.description).toBe('wax on, wax off');
- expect(renamed.frequencyDays).toBe(7);
+ expect(renamed.frequency).toBe('7d');
- const refreq = await edit(r.id, { frequencyDays: 14 }, db);
+ const refreq = await edit(r.id, { frequency: '14d' }, db);
expect(refreq.version).toBe(4);
expect(refreq.name).toBe('clean the whole kitchen');
expect(refreq.description).toBe('wax on, wax off');
@@ -75,7 +75,7 @@ describe('routines service', () => {
});
it('archive inserts an archived snapshot without removing history', async () => {
- const r = await add(projectId, 'clean kitchen', 7, db);
+ const r = await add(projectId, 'clean kitchen', '7d', db);
await archive(r.id, db);
const versions = await loadAllVersions(r.id, db);
expect(versions).toHaveLength(2);
@@ -92,8 +92,8 @@ describe('routines service', () => {
});
it('loadArchived lists archived routines; edits keep the archive date', async () => {
- const a = await add(projectId, 'clean kitchen', 7, db);
- await add(projectId, 'water plants', 3, db);
+ const a = await add(projectId, 'clean kitchen', '7d', db);
+ await add(projectId, 'water plants', '3d', db);
await archive(a.id, db);
const stamped = (await loadAllVersions(a.id, db))[1].archivedAt;
expect(stamped).toBeTruthy();
@@ -105,13 +105,13 @@ describe('routines service', () => {
const archived = await loadArchived(projectId, db);
expect(archived.map((r) => r.id)).toEqual([a.id]);
expect(archived[0].name).toBe('clean kitchen deeply');
- expect(archived[0].frequencyDays).toBe(7);
+ expect(archived[0].frequency).toBe('7d');
expect(archived[0].archivedAt).toBe(stamped);
});
it('loadArchived sorts most recently archived first', async () => {
- const a = await add(projectId, 'first', 1, db);
- const b = await add(projectId, 'second', 1, db);
+ const a = await add(projectId, 'first', '1d', db);
+ const b = await add(projectId, 'second', '1d', db);
await archive(a.id, db);
await archive(b.id, db);
@@ -126,8 +126,8 @@ describe('routines service', () => {
});
it('complete records the snapshot version it was made against', async () => {
- const r = await add(projectId, 'clean kitchen', 7, db);
- await edit(r.id, { frequencyDays: 5 }, db);
+ const r = await add(projectId, 'clean kitchen', '7d', db);
+ await edit(r.id, { frequency: '5d' }, db);
const doneAt = Temporal.Now.instant().toString({
smallestUnit: 'millisecond',
});
@@ -137,7 +137,7 @@ describe('routines service', () => {
});
it('dashboard computes urgency and health from completions', async () => {
- const r = await add(projectId, 'clean kitchen', 7, db);
+ const r = await add(projectId, 'clean kitchen', '7d', db);
const created = Temporal.Instant.from(r.createdAt);
await complete(
r.id,
@@ -157,9 +157,9 @@ describe('routines service', () => {
});
it('overview aggregates per-project health and a global score', async () => {
- await add(projectId, 'clean kitchen', 7, db);
+ await add(projectId, 'clean kitchen', '7d', db);
const growth = await addProject('Personal growth', db);
- await add(growth.id, 'read 30 minutes', 1, db);
+ await add(growth.id, 'read 30 minutes', '1d', db);
const now = Temporal.Now.instant();
const overview = await loadOverview(now, db);
diff --git a/src/lib/server/services/routines.ts b/src/lib/server/services/routines.ts
index 57611d6..b1e210b 100644
--- a/src/lib/server/services/routines.ts
+++ b/src/lib/server/services/routines.ts
@@ -23,7 +23,7 @@ export async function nextRoutineId(db: AppDatabase): Promise<number> {
export async function add(
projectId: number,
name: string,
- frequencyDays: number,
+ frequency: string,
db = defaultDb,
): Promise<Routine> {
const id = await nextRoutineId(db);
@@ -34,7 +34,7 @@ export async function add(
version: 1,
projectId,
name,
- frequencyDays,
+ frequency,
createdAt: Temporal.Now.instant().toString({
smallestUnit: 'millisecond',
}),
@@ -46,7 +46,7 @@ export async function add(
/** Edit = insert a version+1 snapshot, copying unchanged fields forward. */
export async function edit(
id: number,
- fields: Partial<Pick<Routine, 'name' | 'frequencyDays' | 'description'>>,
+ fields: Partial<Pick<Routine, 'name' | 'frequency' | 'description'>>,
db = defaultDb,
): Promise<Routine> {
const [current] = await db
@@ -64,7 +64,7 @@ export async function edit(
version: current.version + 1,
projectId: current.projectId,
name: fields.name ?? current.name,
- frequencyDays: fields.frequencyDays ?? current.frequencyDays,
+ frequency: fields.frequency ?? current.frequency,
description: fields.description ?? current.description,
archived: current.archived,
archivedAt: current.archivedAt,
@@ -96,7 +96,7 @@ export async function archive(id: number, db = defaultDb): Promise<void> {
export interface ArchivedRoutine {
id: number;
name: string;
- frequencyDays: number;
+ frequency: string;
createdAt: string;
archivedAt: string;
}
@@ -125,7 +125,7 @@ export async function loadArchived(
.map((r) => ({
id: r.id,
name: r.name,
- frequencyDays: r.frequencyDays,
+ frequency: r.frequency,
createdAt: r.createdAt,
archivedAt: r.archivedAt ?? r.createdAt,
}))
diff --git a/src/lib/server/services/validation.ts b/src/lib/server/services/validation.ts
index b0a515c..a8cebd9 100644
--- a/src/lib/server/services/validation.ts
+++ b/src/lib/server/services/validation.ts
@@ -1,4 +1,3 @@
-import { parseIntervalToDays } from '$lib/shared/routine-metrics';
import * as v from 'valibot';
export const IdSchema = v.pipe(v.number(), v.integer(), v.minValue(1));
@@ -44,12 +43,3 @@ export function parseOptionalNumber(
if (!Number.isFinite(n)) return null;
return n;
}
-
-/** Parses an interval like '2w' into days; throws on invalid format. */
-export function parseFrequencyDays(value: FormDataEntryValue | null): number {
- const days = parseIntervalToDays(value?.toString() ?? '');
- if (days === null) {
- throw new Error('Invalid interval');
- }
- return days;
-}
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',