aboutsummaryrefslogtreecommitdiff
path: root/src/lib/shared/frequency.ts
blob: 0a82e41f76635d96892730852f04822144ffa3ed (plain) (blame)
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}`;
	}
}