@wasto/sdk 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/index.d.mts +2562 -0
  2. package/dist/index.d.ts +2562 -0
  3. package/dist/index.js +2566 -0
  4. package/dist/index.mjs +2405 -0
  5. package/package.json +30 -0
  6. package/src/client.ts +692 -0
  7. package/src/dashboard-apps.ts +350 -0
  8. package/src/dto/cms-app-settings.ts +91 -0
  9. package/src/enums.ts +298 -0
  10. package/src/free-tier.ts +56 -0
  11. package/src/index.ts +35 -0
  12. package/src/opening-hours/constants.ts +48 -0
  13. package/src/opening-hours/convert.ts +276 -0
  14. package/src/opening-hours/format.ts +99 -0
  15. package/src/opening-hours/index.ts +9 -0
  16. package/src/opening-hours/is-open-now.ts +38 -0
  17. package/src/opening-hours/resolve.ts +71 -0
  18. package/src/opening-hours/time.ts +74 -0
  19. package/src/opening-hours/timezone.ts +34 -0
  20. package/src/opening-hours/types.ts +53 -0
  21. package/src/opening-hours/validate.ts +182 -0
  22. package/src/roi/calculations.ts +732 -0
  23. package/src/roi/index.ts +1 -0
  24. package/src/types/asset-bookings.types.ts +19 -0
  25. package/src/types/asset-interventions.types.ts +24 -0
  26. package/src/types/assets.types.ts +68 -0
  27. package/src/types/auth.types.ts +37 -0
  28. package/src/types/blog-posts.types.ts +19 -0
  29. package/src/types/calendar-pdf.types.ts +24 -0
  30. package/src/types/calendo-events.types.ts +64 -0
  31. package/src/types/challenges.types.ts +30 -0
  32. package/src/types/chatbot.types.ts +82 -0
  33. package/src/types/cities.types.ts +27 -0
  34. package/src/types/city-groups.types.ts +46 -0
  35. package/src/types/cms.types.ts +87 -0
  36. package/src/types/field-reports.types.ts +66 -0
  37. package/src/types/free-tier.types.ts +54 -0
  38. package/src/types/index.ts +32 -0
  39. package/src/types/iot.types.ts +31 -0
  40. package/src/types/maintenance.types.ts +44 -0
  41. package/src/types/mapso.types.ts +146 -0
  42. package/src/types/notifications.types.ts +68 -0
  43. package/src/types/onboarding.types.ts +117 -0
  44. package/src/types/operational-zones.types.ts +70 -0
  45. package/src/types/pagination.types.ts +33 -0
  46. package/src/types/providers.types.ts +29 -0
  47. package/src/types/quiz-questions.types.ts +59 -0
  48. package/src/types/recycling-points.types.ts +47 -0
  49. package/src/types/roi.types.ts +60 -0
  50. package/src/types/surveys.types.ts +103 -0
  51. package/src/types/tickets.types.ts +65 -0
  52. package/src/types/users.types.ts +30 -0
  53. package/src/types/waste-collection-places-query.types.ts +4 -0
  54. package/src/types/waste-collection-places.types.ts +33 -0
  55. package/src/types/waste-types.types.ts +21 -0
@@ -0,0 +1,99 @@
1
+ import type { OpeningHours, OpeningHoursRule, TimeSlot, Weekday, WeeklyHoursRow } from './types';
2
+ import { WEEKDAYS } from './types';
3
+ import { WEEKDAY_LABELS_FR, WEEKDAY_LABELS_FR_SHORT } from './constants';
4
+ import { convertUtcToLocal } from './convert';
5
+ import { formatTimeDisplay, parseTimeMinutes } from './time';
6
+
7
+ function formatSlots(slots: TimeSlot[]): string {
8
+ return slots
9
+ .map((s) => `${formatTimeDisplay(s.open)}–${formatTimeDisplay(s.close)}`)
10
+ .join(', ');
11
+ }
12
+
13
+ function compressDayRanges(days: Weekday[]): string[] {
14
+ if (days.length === 0) return [];
15
+ const order = new Map(WEEKDAYS.map((d, i) => [d, i]));
16
+ const sorted = [...days].sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0));
17
+ const ranges: string[] = [];
18
+ let start = sorted[0];
19
+ let prev = sorted[0];
20
+ let prevIdx = order.get(prev) ?? 0;
21
+
22
+ const flush = (from: Weekday, to: Weekday) => {
23
+ if (from === to) {
24
+ ranges.push(WEEKDAY_LABELS_FR_SHORT[from]);
25
+ } else {
26
+ ranges.push(`${WEEKDAY_LABELS_FR_SHORT[from]}–${WEEKDAY_LABELS_FR_SHORT[to]}`);
27
+ }
28
+ };
29
+
30
+ for (let i = 1; i < sorted.length; i++) {
31
+ const day = sorted[i];
32
+ const idx = order.get(day) ?? 0;
33
+ if (idx === prevIdx + 1) {
34
+ prev = day;
35
+ prevIdx = idx;
36
+ } else {
37
+ flush(start, prev);
38
+ start = day;
39
+ prev = day;
40
+ prevIdx = idx;
41
+ }
42
+ }
43
+ flush(start, prev);
44
+ return ranges;
45
+ }
46
+
47
+ function formatRulesSummary(rules: OpeningHoursRule[]): string {
48
+ const parts: string[] = [];
49
+ for (const rule of rules) {
50
+ if (!rule.days.length || !rule.slots.length) continue;
51
+ const dayPart = compressDayRanges(rule.days).join(', ');
52
+ const slotPart = formatSlots(rule.slots);
53
+ parts.push(`${dayPart} ${slotPart}`);
54
+ }
55
+ return parts.join(' · ');
56
+ }
57
+
58
+ /** Formate un résumé court à partir d'horaires (UTC ou locaux selon le contenu passé). */
59
+ export function formatOpeningHoursSummary(
60
+ hours: OpeningHours | { rules: OpeningHoursRule[] },
61
+ timeZone?: string,
62
+ ): string {
63
+ const rules =
64
+ 'storage' in hours && hours.storage === 'utc' && timeZone
65
+ ? convertUtcToLocal(hours as OpeningHours, timeZone).rules
66
+ : hours.rules;
67
+ return formatRulesSummary(rules);
68
+ }
69
+
70
+ export function formatOpeningHoursWeekly(
71
+ hours: OpeningHours,
72
+ timeZone?: string,
73
+ ): WeeklyHoursRow[] {
74
+ const rules =
75
+ timeZone != null ? convertUtcToLocal(hours, timeZone).rules : hours.rules;
76
+
77
+ const byDay = new Map<Weekday, TimeSlot[]>();
78
+ for (const rule of rules) {
79
+ for (const day of rule.days) {
80
+ const existing = byDay.get(day) ?? [];
81
+ byDay.set(day, [...existing, ...rule.slots]);
82
+ }
83
+ }
84
+
85
+ return WEEKDAYS.map((day) => {
86
+ const slots = byDay.get(day) ?? [];
87
+ return {
88
+ day,
89
+ label: WEEKDAY_LABELS_FR[day],
90
+ closed: slots.length === 0,
91
+ slots,
92
+ };
93
+ });
94
+ }
95
+
96
+ export function formatWeeklyRow(row: WeeklyHoursRow): string {
97
+ if (row.closed) return 'Fermé';
98
+ return formatSlots(row.slots);
99
+ }
@@ -0,0 +1,9 @@
1
+ export * from './types';
2
+ export * from './constants';
3
+ export * from './time';
4
+ export * from './timezone';
5
+ export * from './validate';
6
+ export * from './convert';
7
+ export * from './format';
8
+ export * from './resolve';
9
+ export * from './is-open-now';
@@ -0,0 +1,38 @@
1
+ import type { OpeningHours } from './types';
2
+ import { parseTimeMinutes, weekdayFromUtcDate } from './time';
3
+ import { utcDateIso } from './timezone';
4
+
5
+ export function isOpenNow(
6
+ hours: OpeningHours,
7
+ at: Date = new Date(),
8
+ ): boolean {
9
+ const dateIso = utcDateIso(at);
10
+ const exceptions = hours.exceptions ?? [];
11
+ for (const exc of exceptions) {
12
+ if (exc.date !== dateIso) continue;
13
+ if (exc.closed) return false;
14
+ return isWithinSlots(exc.slots, at);
15
+ }
16
+
17
+ const weekday = weekdayFromUtcDate(at);
18
+ const rules = hours.rules.filter((r) => r.days.includes(weekday));
19
+ for (const rule of rules) {
20
+ if (isWithinSlots(rule.slots, at)) return true;
21
+ }
22
+ return false;
23
+ }
24
+
25
+ function isWithinSlots(slots: { open: string; close: string }[], at: Date): boolean {
26
+ const nowMin = at.getUTCHours() * 60 + at.getUTCMinutes();
27
+ for (const slot of slots) {
28
+ const open = parseTimeMinutes(slot.open);
29
+ const close = parseTimeMinutes(slot.close);
30
+ if (open == null || close == null) continue;
31
+ if (nowMin >= open && nowMin < close) return true;
32
+ }
33
+ return false;
34
+ }
35
+
36
+ export function openNowLabel(hours: OpeningHours, at?: Date): 'Ouvert' | 'Fermé' {
37
+ return isOpenNow(hours, at) ? 'Ouvert' : 'Fermé';
38
+ }
@@ -0,0 +1,71 @@
1
+ import type { HoursInputMode, OpeningHours } from './types';
2
+ import { convertUtcToLocal } from './convert';
3
+ import { formatOpeningHoursSummary } from './format';
4
+ import { getBrowserTimeZone } from './timezone';
5
+
6
+ export type RecyclingPointHoursSource = {
7
+ hours?: string | null;
8
+ details?: Record<string, unknown> | null;
9
+ };
10
+
11
+ export function resolveHoursMode(
12
+ details?: Record<string, unknown> | null,
13
+ ): HoursInputMode {
14
+ if (!details) return 'text';
15
+ const mode = details.hoursMode;
16
+ if (mode === 'structured' || mode === 'text') return mode;
17
+ if (details.openingHours != null) return 'structured';
18
+ return 'text';
19
+ }
20
+
21
+ export function resolveOpeningHoursUtc(
22
+ point: RecyclingPointHoursSource,
23
+ ): OpeningHours | null {
24
+ const details = point.details;
25
+ if (!details || resolveHoursMode(details) !== 'structured') return null;
26
+ const raw = details.openingHours;
27
+ if (!raw || typeof raw !== 'object') return null;
28
+ return raw as OpeningHours;
29
+ }
30
+
31
+ export function resolveOpeningHoursLocal(
32
+ point: RecyclingPointHoursSource,
33
+ timeZone?: string,
34
+ ): { rules: OpeningHours['rules']; exceptions?: OpeningHours['exceptions'] } | null {
35
+ const utc = resolveOpeningHoursUtc(point);
36
+ if (!utc) return null;
37
+ const tz = timeZone ?? getBrowserTimeZone();
38
+ return convertUtcToLocal(utc, tz);
39
+ }
40
+
41
+ export function resolveHoursText(point: RecyclingPointHoursSource): string | null {
42
+ const details = point.details;
43
+ if (resolveHoursMode(details) === 'text') {
44
+ const fromDetails = details?.hours;
45
+ if (typeof fromDetails === 'string' && fromDetails.trim()) return fromDetails.trim();
46
+ if (typeof point.hours === 'string' && point.hours.trim()) return point.hours.trim();
47
+ return null;
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /** Texte d'affichage des horaires (structuré converti ou texte libre). */
53
+ export function resolveHoursDisplay(
54
+ point: RecyclingPointHoursSource,
55
+ timeZone?: string,
56
+ ): string | null {
57
+ const text = resolveHoursText(point);
58
+ if (text) return text;
59
+ const utc = resolveOpeningHoursUtc(point);
60
+ if (!utc) {
61
+ if (typeof point.hours === 'string' && point.hours.trim()) return point.hours.trim();
62
+ return null;
63
+ }
64
+ const tz = timeZone ?? getBrowserTimeZone();
65
+ const summary = formatOpeningHoursSummary(utc, tz);
66
+ return summary || null;
67
+ }
68
+
69
+ export function resolveHoursModeFromPoint(point: RecyclingPointHoursSource): HoursInputMode {
70
+ return resolveHoursMode(point.details ?? undefined);
71
+ }
@@ -0,0 +1,74 @@
1
+ import { WEEKDAYS, type TimeSlot, type Weekday } from './types';
2
+ import { WEEKDAY_INDEX, WEEKDAY_REF_DATES } from './constants';
3
+
4
+ const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
5
+
6
+ export function parseTimeMinutes(value: string): number | null {
7
+ const m = TIME_RE.exec(value.trim());
8
+ if (!m) return null;
9
+ return Number(m[1]) * 60 + Number(m[2]);
10
+ }
11
+
12
+ export function formatTimeMinutes(totalMinutes: number): string {
13
+ const normalized = ((totalMinutes % (24 * 60)) + 24 * 60) % (24 * 60);
14
+ const h = Math.floor(normalized / 60);
15
+ const min = normalized % 60;
16
+ return `${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`;
17
+ }
18
+
19
+ export function formatTimeDisplay(value: string): string {
20
+ const minutes = parseTimeMinutes(value);
21
+ if (minutes == null) return value;
22
+ const h = Math.floor(minutes / 60);
23
+ const min = minutes % 60;
24
+ return min === 0 ? `${h}h` : `${h}h${String(min).padStart(2, '0')}`;
25
+ }
26
+
27
+ export function slotsOverlap(a: TimeSlot, b: TimeSlot): boolean {
28
+ const aOpen = parseTimeMinutes(a.open);
29
+ const aClose = parseTimeMinutes(a.close);
30
+ const bOpen = parseTimeMinutes(b.open);
31
+ const bClose = parseTimeMinutes(b.close);
32
+ if (aOpen == null || aClose == null || bOpen == null || bClose == null) return false;
33
+ if (aClose <= aOpen || bClose <= bOpen) return false;
34
+ return aOpen < bClose && bOpen < aClose;
35
+ }
36
+
37
+ export function sortSlots(slots: TimeSlot[]): TimeSlot[] {
38
+ return [...slots].sort(
39
+ (a, b) => (parseTimeMinutes(a.open) ?? 0) - (parseTimeMinutes(b.open) ?? 0),
40
+ );
41
+ }
42
+
43
+ export function nextWeekday(day: Weekday): Weekday {
44
+ const idx = (WEEKDAY_INDEX[day] + 1) % 7;
45
+ return WEEKDAYS[idx];
46
+ }
47
+
48
+ export function addDaysToIsoDate(isoDate: string, days: number): string {
49
+ const [y, mo, d] = isoDate.split('-').map(Number);
50
+ const dt = new Date(Date.UTC(y, mo - 1, d + days));
51
+ return dt.toISOString().slice(0, 10);
52
+ }
53
+
54
+ export function weekdayFromUtcDate(date: Date): Weekday {
55
+ const jsDay = date.getUTCDay();
56
+ const map: Weekday[] = [
57
+ 'sunday',
58
+ 'monday',
59
+ 'tuesday',
60
+ 'wednesday',
61
+ 'thursday',
62
+ 'friday',
63
+ 'saturday',
64
+ ];
65
+ return map[jsDay];
66
+ }
67
+
68
+ export function utcHhmmFromDate(date: Date): string {
69
+ return formatTimeMinutes(date.getUTCHours() * 60 + date.getUTCMinutes());
70
+ }
71
+
72
+ export function refDateForWeekday(day: Weekday): string {
73
+ return WEEKDAY_REF_DATES[day];
74
+ }
@@ -0,0 +1,34 @@
1
+ /** Offset du fuseau IANA en minutes (positif = en avance sur UTC) à un instant donné. */
2
+ export function getTimezoneOffsetMinutes(timeZone: string, at: Date): number {
3
+ const utc = new Date(at.toLocaleString('en-US', { timeZone: 'UTC' }));
4
+ const zoned = new Date(at.toLocaleString('en-US', { timeZone }));
5
+ return (zoned.getTime() - utc.getTime()) / 60_000;
6
+ }
7
+
8
+ /** Convertit une date/heure locale (dans timeZone) en instant UTC. */
9
+ export function localDateTimeToUtc(
10
+ dateIso: string,
11
+ time: string,
12
+ timeZone: string,
13
+ ): Date {
14
+ const [y, mo, d] = dateIso.split('-').map(Number);
15
+ const [h, mi] = time.split(':').map(Number);
16
+ let utcMs = Date.UTC(y, mo - 1, d, h, mi, 0);
17
+ for (let i = 0; i < 2; i++) {
18
+ const offset = getTimezoneOffsetMinutes(timeZone, new Date(utcMs));
19
+ utcMs = Date.UTC(y, mo - 1, d, h, mi, 0) - offset * 60_000;
20
+ }
21
+ return new Date(utcMs);
22
+ }
23
+
24
+ export function getBrowserTimeZone(): string {
25
+ try {
26
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
27
+ } catch {
28
+ return 'UTC';
29
+ }
30
+ }
31
+
32
+ export function utcDateIso(at: Date): string {
33
+ return at.toISOString().slice(0, 10);
34
+ }
@@ -0,0 +1,53 @@
1
+ export const WEEKDAYS = [
2
+ 'monday',
3
+ 'tuesday',
4
+ 'wednesday',
5
+ 'thursday',
6
+ 'friday',
7
+ 'saturday',
8
+ 'sunday',
9
+ ] as const;
10
+
11
+ export type Weekday = (typeof WEEKDAYS)[number];
12
+
13
+ export type TimeSlot = { open: string; close: string };
14
+
15
+ export type OpeningHoursRule = { days: Weekday[]; slots: TimeSlot[] };
16
+
17
+ export type OpeningHoursExceptionClosed = {
18
+ date: string;
19
+ closed: true;
20
+ label?: string;
21
+ };
22
+
23
+ export type OpeningHoursExceptionOpen = {
24
+ date: string;
25
+ closed: false;
26
+ slots: TimeSlot[];
27
+ label?: string;
28
+ };
29
+
30
+ export type OpeningHoursException =
31
+ | OpeningHoursExceptionClosed
32
+ | OpeningHoursExceptionOpen;
33
+
34
+ /** Horaires structurés persistés en UTC+0 */
35
+ export type OpeningHours = {
36
+ version: 1;
37
+ storage: 'utc';
38
+ rules: OpeningHoursRule[];
39
+ exceptions?: OpeningHoursException[];
40
+ };
41
+
42
+ export type HoursInputMode = 'structured' | 'text';
43
+
44
+ export type WeeklyHoursRow = {
45
+ day: Weekday;
46
+ label: string;
47
+ closed: boolean;
48
+ slots: TimeSlot[];
49
+ };
50
+
51
+ export type OpeningHoursValidationResult =
52
+ | { ok: true; value: OpeningHours }
53
+ | { ok: false; errors: string[] };
@@ -0,0 +1,182 @@
1
+ import {
2
+ WEEKDAYS,
3
+ type OpeningHours,
4
+ type OpeningHoursException,
5
+ type OpeningHoursRule,
6
+ type OpeningHoursValidationResult,
7
+ type TimeSlot,
8
+ type Weekday,
9
+ } from './types';
10
+ import { parseTimeMinutes, slotsOverlap, sortSlots } from './time';
11
+
12
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
13
+
14
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
15
+ return v != null && typeof v === 'object' && !Array.isArray(v);
16
+ }
17
+
18
+ function validateTimeSlot(slot: unknown, path: string, errors: string[]): slot is TimeSlot {
19
+ if (!isPlainObject(slot)) {
20
+ errors.push(`${path} : créneau invalide`);
21
+ return false;
22
+ }
23
+ const open = slot.open;
24
+ const close = slot.close;
25
+ if (typeof open !== 'string' || parseTimeMinutes(open) == null) {
26
+ errors.push(`${path}.open : heure invalide (HH:mm)`);
27
+ return false;
28
+ }
29
+ if (typeof close !== 'string' || parseTimeMinutes(close) == null) {
30
+ errors.push(`${path}.close : heure invalide (HH:mm)`);
31
+ return false;
32
+ }
33
+ const openMin = parseTimeMinutes(open)!;
34
+ const closeMin = parseTimeMinutes(close)!;
35
+ if (closeMin <= openMin) {
36
+ errors.push(`${path} : l'heure de fermeture doit être après l'ouverture (UTC)`);
37
+ return false;
38
+ }
39
+ return true;
40
+ }
41
+
42
+ function validateRule(rule: unknown, index: number, errors: string[]): rule is OpeningHoursRule {
43
+ if (!isPlainObject(rule)) {
44
+ errors.push(`rules[${index}] : règle invalide`);
45
+ return false;
46
+ }
47
+ if (!Array.isArray(rule.days) || rule.days.length === 0) {
48
+ errors.push(`rules[${index}].days : au moins un jour requis`);
49
+ return false;
50
+ }
51
+ for (const day of rule.days) {
52
+ if (typeof day !== 'string' || !WEEKDAYS.includes(day as Weekday)) {
53
+ errors.push(`rules[${index}].days : jour invalide (${String(day)})`);
54
+ }
55
+ }
56
+ if (!Array.isArray(rule.slots) || rule.slots.length === 0) {
57
+ errors.push(`rules[${index}].slots : au moins un créneau requis`);
58
+ return false;
59
+ }
60
+ const slots: TimeSlot[] = [];
61
+ rule.slots.forEach((slot, slotIndex) => {
62
+ if (validateTimeSlot(slot, `rules[${index}].slots[${slotIndex}]`, errors)) {
63
+ slots.push(slot);
64
+ }
65
+ });
66
+ const sorted = sortSlots(slots);
67
+ for (let i = 0; i < sorted.length - 1; i++) {
68
+ if (slotsOverlap(sorted[i], sorted[i + 1])) {
69
+ errors.push(`rules[${index}].slots : créneaux qui se chevauchent`);
70
+ break;
71
+ }
72
+ }
73
+ return errors.length === 0;
74
+ }
75
+
76
+ function validateException(
77
+ exc: unknown,
78
+ index: number,
79
+ errors: string[],
80
+ ): exc is OpeningHoursException {
81
+ if (!isPlainObject(exc)) {
82
+ errors.push(`exceptions[${index}] : exception invalide`);
83
+ return false;
84
+ }
85
+ if (typeof exc.date !== 'string' || !DATE_RE.test(exc.date)) {
86
+ errors.push(`exceptions[${index}].date : date invalide (YYYY-MM-DD)`);
87
+ return false;
88
+ }
89
+ if (exc.closed === true) return true;
90
+ if (exc.closed === false) {
91
+ if (!Array.isArray(exc.slots) || exc.slots.length === 0) {
92
+ errors.push(`exceptions[${index}].slots : requis si non fermé`);
93
+ return false;
94
+ }
95
+ exc.slots.forEach((slot, slotIndex) => {
96
+ validateTimeSlot(slot, `exceptions[${index}].slots[${slotIndex}]`, errors);
97
+ });
98
+ return true;
99
+ }
100
+ errors.push(`exceptions[${index}].closed : booléen requis`);
101
+ return false;
102
+ }
103
+
104
+ export function validateOpeningHours(input: unknown): OpeningHoursValidationResult {
105
+ const errors: string[] = [];
106
+ if (!isPlainObject(input)) {
107
+ return { ok: false, errors: ['Horaires : objet attendu'] };
108
+ }
109
+ if (input.version !== 1) {
110
+ errors.push('version : doit être 1');
111
+ }
112
+ if (input.storage !== 'utc') {
113
+ errors.push('storage : doit être "utc"');
114
+ }
115
+ if (!Array.isArray(input.rules)) {
116
+ errors.push('rules : tableau attendu');
117
+ return { ok: false, errors };
118
+ }
119
+ input.rules.forEach((rule, index) => validateRule(rule, index, errors));
120
+
121
+ const daySet = new Set<Weekday>();
122
+ if (Array.isArray(input.rules)) {
123
+ for (const rule of input.rules) {
124
+ if (!isPlainObject(rule) || !Array.isArray(rule.days)) continue;
125
+ for (const day of rule.days) {
126
+ if (typeof day === 'string' && WEEKDAYS.includes(day as Weekday)) {
127
+ const wd = day as Weekday;
128
+ if (daySet.has(wd)) {
129
+ errors.push(`rules : le jour ${wd} apparaît dans plusieurs règles`);
130
+ }
131
+ daySet.add(wd);
132
+ }
133
+ }
134
+ }
135
+ }
136
+
137
+ if (input.exceptions != null) {
138
+ if (!Array.isArray(input.exceptions)) {
139
+ errors.push('exceptions : tableau attendu');
140
+ } else {
141
+ input.exceptions.forEach((exc, index) => validateException(exc, index, errors));
142
+ }
143
+ }
144
+
145
+ if (errors.length > 0) {
146
+ return { ok: false, errors };
147
+ }
148
+
149
+ return {
150
+ ok: true,
151
+ value: {
152
+ version: 1,
153
+ storage: 'utc',
154
+ rules: (input.rules as OpeningHoursRule[]).map((r) => ({
155
+ days: [...r.days],
156
+ slots: sortSlots([...r.slots]),
157
+ })),
158
+ exceptions: input.exceptions as OpeningHoursException[] | undefined,
159
+ },
160
+ };
161
+ }
162
+
163
+ /** Valide des horaires saisis en heure locale (même structure, sans storage/version). */
164
+ export function validateLocalOpeningHours(input: {
165
+ rules: OpeningHoursRule[];
166
+ exceptions?: OpeningHoursException[];
167
+ }): OpeningHoursValidationResult {
168
+ return validateOpeningHours({
169
+ version: 1,
170
+ storage: 'utc',
171
+ rules: input.rules,
172
+ exceptions: input.exceptions,
173
+ });
174
+ }
175
+
176
+ export function createEmptyOpeningHours(): OpeningHours {
177
+ return { version: 1, storage: 'utc', rules: [] };
178
+ }
179
+
180
+ export function createEmptyLocalRules(): OpeningHoursRule[] {
181
+ return [];
182
+ }