From a725d4c7d28fee701b6997cc46cffddefbcd26bf Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Wed, 30 Sep 2026 22:13:07 +0200 Subject: Allow granular routine frequencies like 5/2w --- src/lib/shared/frequency.ts | 114 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 src/lib/shared/frequency.ts (limited to 'src/lib/shared/frequency.ts') 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 = { d: 1, w: 7, m: 30, y: 365 }; + +const UNIT_LABELS: Record = { + 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 = { + 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}`; + } +} -- cgit v1.3