1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
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}`;
}
}
|