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}`; } }