aboutsummaryrefslogtreecommitdiff
path: root/src/lib/shared/frequency.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/shared/frequency.ts')
-rw-r--r--src/lib/shared/frequency.ts114
1 files changed, 114 insertions, 0 deletions
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}`;
+ }
+}