@wasto/cms-components 1.5.10 → 1.5.12

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 (101) hide show
  1. package/README.md +47 -47
  2. package/dist/index.d.mts +181 -51
  3. package/dist/index.d.ts +181 -51
  4. package/dist/index.js +3614 -3015
  5. package/dist/index.mjs +3021 -2418
  6. package/package.json +12 -2
  7. package/src/components/announcement-modal/AnnouncementModal.tsx +183 -183
  8. package/src/components/article/Article.tsx +21 -21
  9. package/src/components/article/ArticleCard.tsx +81 -81
  10. package/src/components/article/ArticleExcerpts.tsx +80 -80
  11. package/src/components/article/ArticleList.tsx +114 -114
  12. package/src/components/article/ArticleMarkdown.tsx +170 -170
  13. package/src/components/article/ArticleReaderModal.tsx +93 -93
  14. package/src/components/article/ArticleSingle.tsx +57 -57
  15. package/src/components/bottom-tab/BottomTab.tsx +392 -392
  16. package/src/components/button/Button.tsx +154 -154
  17. package/src/components/challenge/Badges.tsx +84 -84
  18. package/src/components/challenge/BadgesModal.tsx +166 -166
  19. package/src/components/challenge/Challenge.tsx +380 -380
  20. package/src/components/challenge/ChallengeModal.tsx +231 -231
  21. package/src/components/challenge/ChallengeVignette.tsx +249 -249
  22. package/src/components/challenge/ChallengesModal.tsx +243 -243
  23. package/src/components/challenge/QuizModal.tsx +381 -381
  24. package/src/components/challenge/ResultModal.tsx +144 -144
  25. package/src/components/chat/Chat.tsx +928 -928
  26. package/src/components/collection-calendar/CollectionCalendar.tsx +274 -274
  27. package/src/components/columns/Columns.tsx +33 -33
  28. package/src/components/consignes-modal/ConsignesModal.tsx +267 -267
  29. package/src/components/content-inset/ContentInset.tsx +33 -33
  30. package/src/components/header/Header.tsx +415 -391
  31. package/src/components/image/Image.tsx +15 -15
  32. package/src/components/image/ImageFrame.tsx +102 -102
  33. package/src/components/image/ImageMedia.tsx +31 -31
  34. package/src/components/image/ImageUrl.tsx +30 -30
  35. package/src/components/locations-map/LocationSheet.tsx +417 -417
  36. package/src/components/locations-map/LocationsMap.native.tsx +260 -260
  37. package/src/components/locations-map/LocationsMap.tsx +13 -13
  38. package/src/components/locations-map/LocationsMap.web.tsx +10 -10
  39. package/src/components/locations-map/LocationsMapShell.tsx +546 -527
  40. package/src/components/locations-map/LocationsMapView.tsx +108 -108
  41. package/src/components/locations-map/LocationsMapView.web.tsx +331 -331
  42. package/src/components/nav-button/NavButton.tsx +59 -59
  43. package/src/components/next-collection/NextCollection.tsx +636 -621
  44. package/src/components/notifications/NotificationsScreen.tsx +247 -247
  45. package/src/components/page/Page.tsx +102 -64
  46. package/src/components/parapraph/Paragraph.tsx +67 -67
  47. package/src/components/profile/ProfileScreen.tsx +236 -236
  48. package/src/components/push/PushTokenRegistrar.tsx +47 -0
  49. package/src/components/recycling-info/RecyclingInfo.tsx +189 -189
  50. package/src/components/safe-area/OptionalSafeAreaProvider.tsx +17 -17
  51. package/src/components/search/Search.tsx +320 -320
  52. package/src/components/select-street-modal/SelectStreetModal.tsx +475 -475
  53. package/src/components/title/Title.tsx +100 -100
  54. package/src/components/vision/Vision.tsx +447 -447
  55. package/src/components/vision/VisionActionCard.tsx +117 -117
  56. package/src/components/vision/VisionCapture.tsx +30 -30
  57. package/src/components/vision/VisionTapOverlay.tsx +122 -122
  58. package/src/contexts/AnnouncementsContext.tsx +171 -171
  59. package/src/contexts/AppDataContext.tsx +458 -0
  60. package/src/contexts/ChallengesContext.tsx +182 -182
  61. package/src/contexts/HeaderConfigContext.tsx +29 -0
  62. package/src/contexts/LocalContext.tsx +227 -227
  63. package/src/contexts/ThemeContext.tsx +37 -37
  64. package/src/contexts/WastoContext.tsx +132 -127
  65. package/src/helpers/blogPost.ts +72 -72
  66. package/src/helpers/generateQuizQuestions.ts +247 -247
  67. package/src/helpers/getContrastColor.ts +18 -18
  68. package/src/helpers/haptics.ts +20 -20
  69. package/src/helpers/hoursSchedule.ts +230 -230
  70. package/src/helpers/htmlContentToMarkdown.ts +114 -114
  71. package/src/helpers/layout.ts +54 -54
  72. package/src/helpers/loadLinearGradient.ts +22 -22
  73. package/src/helpers/mapLocations.ts +170 -0
  74. package/src/helpers/maquetteTheme.ts +76 -76
  75. package/src/helpers/markdownUnderlinePlugin.ts +32 -32
  76. package/src/helpers/nextCollection.ts +122 -0
  77. package/src/helpers/normalizeWasteTypeRecyclingInfo.ts +80 -80
  78. package/src/helpers/publicMediaUrl.ts +8 -8
  79. package/src/helpers/resolveApiAssetUrl.ts +21 -21
  80. package/src/helpers/systemRoutes.ts +7 -7
  81. package/src/helpers/themeStyles.ts +114 -114
  82. package/src/helpers/ttlCache.ts +41 -0
  83. package/src/helpers/useSafeAreaPadding.ts +14 -14
  84. package/src/hooks/useBadges.ts +100 -100
  85. package/src/hooks/useBlogPosts.ts +109 -105
  86. package/src/hooks/useChallenges.ts +110 -110
  87. package/src/hooks/useCities.ts +56 -56
  88. package/src/hooks/useCityAnnouncements.ts +118 -118
  89. package/src/hooks/useCollectionCalendar.ts +421 -421
  90. package/src/hooks/useColumns.ts +19 -19
  91. package/src/hooks/useConsignes.ts +62 -62
  92. package/src/hooks/useLocations.ts +120 -248
  93. package/src/hooks/useNextCollection.ts +163 -238
  94. package/src/hooks/useRecyclingInfo.ts +90 -90
  95. package/src/hooks/useWastoClient.ts +16 -16
  96. package/src/index.ts +161 -142
  97. package/src/navigation/NavigationContext.tsx +56 -56
  98. package/src/push/registerExpoPushToken.ts +133 -0
  99. package/src/sentry/initSentry.ts +120 -120
  100. package/src/types/Theme.ts +72 -72
  101. package/src/types/css-modules.d.ts +1 -1
@@ -1,421 +1,421 @@
1
- import { useCallback, useMemo, useState } from "react";
2
- import { useLocal } from "../contexts/LocalContext";
3
- import { useNextCollection, type WasteCollectionPlace, type WasteType } from "./useNextCollection";
4
-
5
- export const MONTHS = [
6
- "Janvier",
7
- "Février",
8
- "Mars",
9
- "Avril",
10
- "Mai",
11
- "Juin",
12
- "Juillet",
13
- "Août",
14
- "Septembre",
15
- "Octobre",
16
- "Novembre",
17
- "Décembre",
18
- ] as const;
19
-
20
- const DAY_MAPPING: Record<string, number> = {
21
- Dimanche: 6,
22
- Lundi: 0,
23
- Mardi: 1,
24
- Mercredi: 2,
25
- Jeudi: 3,
26
- Vendredi: 4,
27
- Samedi: 5,
28
- };
29
-
30
- export type CollectionScheduleEntry = {
31
- dayOfWeek: number; // lundi=0 ... dimanche=6
32
- wasteTypeId: string;
33
- color: string;
34
- biweekly?: boolean;
35
- startDate?: string;
36
- };
37
-
38
- export type CalendarException = {
39
- dateFrom: string;
40
- dateTo: string;
41
- wasteTypeId?: string;
42
- };
43
-
44
- export type WasteTypeInfoForDay = {
45
- wasteTypeId: string;
46
- color: string;
47
- /** Marqueur utilisé dans l’UI historique: “retour” d’exception */
48
- isException?: boolean;
49
- hasActiveException?: boolean;
50
- };
51
-
52
- /** Coordonnées d’une case (sans données de collecte). */
53
- export type CalendarDayCellCore = {
54
- day: number;
55
- month: number;
56
- year: number;
57
- /** `true` si le jour appartient au mois affiché dans le titre. */
58
- inCurrentMonth: boolean;
59
- };
60
-
61
- /** Case prête à l’affichage : planning global + exceptions **de ce jour uniquement** + collectes du jour. */
62
- export type CalendarDayCell = CalendarDayCellCore & {
63
- schedule: CollectionScheduleEntry[];
64
- exceptions: CalendarException[];
65
- wasteTypesForDay: WasteTypeInfoForDay[];
66
- };
67
-
68
- export type UseCollectionCalendarOptions = {
69
- /** Si omis, on utilise `LocalContext.selectedCityId`. */
70
- cityId?: string | null;
71
- /** Si omis, on utilise `LocalContext.selectedCityId` + 1er lieu disponible. */
72
- selectedPlaceId?: string | null;
73
- /** Mois initial (0-11). Par défaut: mois courant. */
74
- initialMonth?: number;
75
- /** Année initiale. Par défaut: année courante. */
76
- initialYear?: number;
77
- };
78
-
79
- export type UseCollectionCalendarResult = {
80
- cityId: string | null;
81
- isLoading: boolean;
82
- error: string | null;
83
-
84
- currentMonth: number;
85
- currentYear: number;
86
- monthLabel: string;
87
- setCurrentMonth: (m: number) => void;
88
- setCurrentYear: (y: number) => void;
89
- goToPrevMonth: () => void;
90
- goToNextMonth: () => void;
91
-
92
- weekDays: string[];
93
- /** Semaines complètes (7 colonnes), avec jours du mois précédent / suivant pour remplir la grille. */
94
- weeks: CalendarDayCell[][];
95
-
96
- selectedPlaceId: string | null;
97
-
98
- /** Collectes pour une date précise (utile pour les cases en débordement). */
99
- getWasteTypesForDate: (year: number, month: number, day: number) => WasteTypeInfoForDay[];
100
- /** Raccourci : jour du **mois affiché** uniquement (même que `getWasteTypesForDate(currentYear, currentMonth, day)`). */
101
- getWasteTypesForDay: (day: number) => WasteTypeInfoForDay[];
102
- };
103
-
104
- function clampMonth(m: number): number {
105
- if (!Number.isFinite(m)) return new Date().getMonth();
106
- return Math.max(0, Math.min(11, Math.floor(m)));
107
- }
108
-
109
- /**
110
- * Parse `dateFrom` / `dateTo` renvoyés par l’API (souvent ISO complet `...T00:00:00.000Z`).
111
- * Évite `${date}T00:00:00` qui produit une chaîne invalide si la valeur contient déjà `T…`.
112
- * Extrait `YYYY-MM-DD` et construit une date **locale** à minuit (alignée sur les cases du calendrier).
113
- */
114
- function exceptionBoundaryToLocalDate(value: string): Date {
115
- const trimmed = value.trim();
116
- const datePart = trimmed.includes("T") ? trimmed.split("T")[0]! : trimmed;
117
- const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(datePart);
118
- if (m) {
119
- return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 0, 0, 0, 0);
120
- }
121
- const d = new Date(trimmed);
122
- if (!Number.isNaN(d.getTime())) {
123
- d.setHours(0, 0, 0, 0);
124
- return d;
125
- }
126
- return new Date(NaN);
127
- }
128
-
129
- /**
130
- * Grille calendrier : semaines de 7 jours, semaine commençant lundi.
131
- * Les cases avant le 1er et après le dernier jour du mois utilisent le mois précédent / suivant.
132
- */
133
- export function buildCalendarWeeks(year: number, month: number): CalendarDayCellCore[][] {
134
- const daysInMonth = new Date(year, month + 1, 0).getDate();
135
- const firstDayOfMonth = new Date(year, month, 1).getDay();
136
- const mondayOffset = firstDayOfMonth === 0 ? 6 : firstDayOfMonth - 1;
137
-
138
- const prevMonth = month === 0 ? 11 : month - 1;
139
- const prevYear = month === 0 ? year - 1 : year;
140
- const daysInPrevMonth = new Date(year, month, 0).getDate();
141
-
142
- const cells: CalendarDayCellCore[] = [];
143
-
144
- for (let i = 0; i < mondayOffset; i += 1) {
145
- const d = daysInPrevMonth - mondayOffset + 1 + i;
146
- cells.push({ day: d, month: prevMonth, year: prevYear, inCurrentMonth: false });
147
- }
148
-
149
- for (let d = 1; d <= daysInMonth; d += 1) {
150
- cells.push({ day: d, month, year, inCurrentMonth: true });
151
- }
152
-
153
- const nextMonth = month === 11 ? 0 : month + 1;
154
- const nextYear = month === 11 ? year + 1 : year;
155
- let nextDay = 1;
156
- while (cells.length % 7 !== 0) {
157
- cells.push({ day: nextDay, month: nextMonth, year: nextYear, inCurrentMonth: false });
158
- nextDay += 1;
159
- }
160
-
161
- const weeks: CalendarDayCellCore[][] = [];
162
- for (let i = 0; i < cells.length; i += 7) {
163
- weeks.push(cells.slice(i, i + 7));
164
- }
165
- return weeks;
166
- }
167
-
168
- /**
169
- * Exceptions qui concernent la date calendaire donnée (aligné sur {@link computeWasteTypesForDay} : [dateFrom, dateTo) ;
170
- * si dateFrom === dateTo, traité comme une exception d’une seule journée).
171
- */
172
- export function exceptionsForCalendarDay(
173
- day: number,
174
- year: number,
175
- month: number,
176
- allExceptions: CalendarException[]
177
- ): CalendarException[] {
178
- const cell = new Date(year, month, day);
179
- cell.setHours(0, 0, 0, 0);
180
- const c = cell.getTime();
181
-
182
- return allExceptions.filter((exc) => {
183
- const from = exceptionBoundaryToLocalDate(exc.dateFrom);
184
- const to = exceptionBoundaryToLocalDate(exc.dateTo);
185
- const f = from.getTime();
186
- const t = to.getTime();
187
- if (f === t) {
188
- return c === f;
189
- }
190
- return c >= f && c < t;
191
- });
192
- }
193
-
194
- function enrichCalendarWeeks(
195
- base: CalendarDayCellCore[][],
196
- schedule: CollectionScheduleEntry[],
197
- allExceptions: CalendarException[]
198
- ): CalendarDayCell[][] {
199
- return base.map((row) =>
200
- row.map((cell) => {
201
- const exceptionsForDay = exceptionsForCalendarDay(cell.day, cell.year, cell.month, allExceptions);
202
- return {
203
- ...cell,
204
- schedule,
205
- exceptions: exceptionsForDay,
206
- wasteTypesForDay: computeWasteTypesForDay(
207
- cell.day,
208
- cell.year,
209
- cell.month,
210
- schedule,
211
- allExceptions
212
- ),
213
- };
214
- })
215
- );
216
- }
217
-
218
- /** @deprecated Préférer `buildCalendarWeeks` pour une grille avec débordements. */
219
- export function getDaysInMonth(year: number, month: number): Array<number | null> {
220
- const daysInMonth = new Date(year, month + 1, 0).getDate();
221
- const firstDayOfMonth = new Date(year, month, 1).getDay();
222
- const mondayBasedDay = firstDayOfMonth === 0 ? 6 : firstDayOfMonth - 1;
223
- const days: Array<number | null> = [];
224
- for (let i = 0; i < mondayBasedDay; i += 1) days.push(null);
225
- for (let day = 1; day <= daysInMonth; day += 1) days.push(day);
226
- return days;
227
- }
228
-
229
- function getWeeksSinceStart(startDateStr: string, currentDate: Date): number {
230
- const startDate = new Date(startDateStr);
231
- const diffDays = Math.floor((currentDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
232
- return Math.floor(diffDays / 7);
233
- }
234
-
235
- function buildSchedule(
236
- wasteTypes: WasteType[],
237
- places: WasteCollectionPlace[],
238
- selectedPlaceId?: string | null
239
- ): { schedule: CollectionScheduleEntry[]; exceptions: CalendarException[]; resolvedPlaceId: string | null } {
240
- const safePlaces = (places || []) as WasteCollectionPlace[];
241
- if (!safePlaces.length) {
242
- return { schedule: [], exceptions: [], resolvedPlaceId: null };
243
- }
244
-
245
- const selected =
246
- (selectedPlaceId
247
- ? safePlaces.find((p) => p.id === selectedPlaceId)
248
- : undefined) ?? safePlaces[0];
249
-
250
- const wasteTypeMap: Record<string, { color?: string }> = {};
251
- (wasteTypes || []).forEach((wt) => {
252
- if (wt?.id) wasteTypeMap[wt.id] = { color: wt.color };
253
- });
254
-
255
- const rawDays = (selected.collectionDays || []) as Array<{
256
- name: string;
257
- wasteTypeId: string;
258
- biweekly?: boolean;
259
- startDate?: string;
260
- }>;
261
-
262
- const schedule: CollectionScheduleEntry[] = [];
263
- for (const day of rawDays) {
264
- const dayIndex = DAY_MAPPING[day.name];
265
- if (dayIndex === undefined) continue;
266
- const color = wasteTypeMap[day.wasteTypeId]?.color;
267
- schedule.push({
268
- dayOfWeek: dayIndex,
269
- wasteTypeId: day.wasteTypeId,
270
- color: typeof color === "string" && color.trim().length > 0 ? color : "#7C7C7C",
271
- biweekly: day.biweekly,
272
- startDate: day.startDate,
273
- });
274
- }
275
-
276
- const exceptions = ((selected as unknown as { exceptions?: unknown[] })?.exceptions || []) as CalendarException[];
277
-
278
- return { schedule, exceptions, resolvedPlaceId: selected?.id ?? null };
279
- }
280
-
281
- function computeWasteTypesForDay(
282
- day: number,
283
- year: number,
284
- month: number,
285
- schedule: CollectionScheduleEntry[],
286
- exceptions: CalendarException[]
287
- ): WasteTypeInfoForDay[] {
288
- const date = new Date(year, month, day);
289
- const dayOfWeek = date.getDay();
290
- const mondayBasedDay = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
291
-
292
- const daySchedules = schedule.filter((s) => s.dayOfWeek === mondayBasedDay);
293
-
294
- const wasteTypes: WasteTypeInfoForDay[] = [];
295
- for (const s of daySchedules) {
296
- if (s.biweekly && s.startDate) {
297
- const weeksSinceStart = getWeeksSinceStart(s.startDate, date);
298
- if (weeksSinceStart >= 0 && weeksSinceStart % 2 === 0) {
299
- wasteTypes.push({ wasteTypeId: s.wasteTypeId, color: s.color });
300
- }
301
- } else if (!s.biweekly) {
302
- wasteTypes.push({ wasteTypeId: s.wasteTypeId, color: s.color });
303
- }
304
- }
305
-
306
- // Filtre des exceptions: si l’exception cible un wasteTypeId précis, on ne garde pas ce type pendant la période.
307
- const filtered: WasteTypeInfoForDay[] = [];
308
- for (const wt of wasteTypes) {
309
- const hasActiveException = exceptions.some((exc) => {
310
- if (exc?.wasteTypeId && exc.wasteTypeId !== wt.wasteTypeId) return false;
311
- const from = exceptionBoundaryToLocalDate(exc.dateFrom);
312
- const to = exceptionBoundaryToLocalDate(exc.dateTo);
313
- return date.getTime() >= from.getTime() && date.getTime() < to.getTime();
314
- });
315
- if (!hasActiveException) {
316
- filtered.push({ ...wt, hasActiveException: false });
317
- }
318
- }
319
-
320
- // “Retour” d’exception: quand on tombe exactement sur dateTo, on peut marquer le type comme exception (UI ✓).
321
- for (const exc of exceptions) {
322
- if (!exc?.wasteTypeId) continue;
323
- const dateToBoundary = exceptionBoundaryToLocalDate(exc.dateTo);
324
- if (date.getTime() !== dateToBoundary.getTime()) continue;
325
- const matching = schedule.find((s) => s.wasteTypeId === exc.wasteTypeId);
326
- if (!matching) continue;
327
- if (!filtered.some((x) => x.wasteTypeId === matching.wasteTypeId)) {
328
- filtered.push({
329
- wasteTypeId: matching.wasteTypeId,
330
- color: matching.color,
331
- isException: true,
332
- hasActiveException: false,
333
- });
334
- }
335
- }
336
-
337
- return filtered;
338
- }
339
-
340
- export function useCollectionCalendar(options: UseCollectionCalendarOptions = {}): UseCollectionCalendarResult {
341
- const { selectedCityId, selectedPlaceId: localPlaceId } = useLocal();
342
-
343
- const resolvedCityId = options.cityId ?? selectedCityId ?? null;
344
- const resolvedPlaceIdOption = options.selectedPlaceId ?? localPlaceId ?? null;
345
- const today = new Date();
346
- const initialMonth = clampMonth(options.initialMonth ?? today.getMonth());
347
- const initialYear = Number.isFinite(options.initialYear) ? (options.initialYear as number) : today.getFullYear();
348
-
349
- const [currentMonth, setCurrentMonth] = useState<number>(initialMonth);
350
- const [currentYear, setCurrentYear] = useState<number>(initialYear);
351
-
352
- const { wasteTypes, wasteCollectionPlaces, isLoading, error } = useNextCollection({
353
- cityId: resolvedCityId,
354
- selectedPlaceId: resolvedPlaceIdOption,
355
- });
356
-
357
- const { schedule, exceptions, resolvedPlaceId } = useMemo(
358
- () => buildSchedule(wasteTypes, wasteCollectionPlaces, resolvedPlaceIdOption),
359
- [wasteTypes, wasteCollectionPlaces, resolvedPlaceIdOption]
360
- );
361
-
362
- const goToPrevMonth = useCallback(() => {
363
- setCurrentMonth((m) => {
364
- if (m === 0) {
365
- setCurrentYear((y) => y - 1);
366
- return 11;
367
- }
368
- return m - 1;
369
- });
370
- }, []);
371
-
372
- const goToNextMonth = useCallback(() => {
373
- setCurrentMonth((m) => {
374
- if (m === 11) {
375
- setCurrentYear((y) => y + 1);
376
- return 0;
377
- }
378
- return m + 1;
379
- });
380
- }, []);
381
-
382
- const weeks = useMemo(() => {
383
- const base = buildCalendarWeeks(currentYear, currentMonth);
384
- return enrichCalendarWeeks(base, schedule, exceptions);
385
- }, [currentYear, currentMonth, schedule, exceptions]);
386
- const weekDays = useMemo(() => ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"], []);
387
-
388
- const getWasteTypesForDate = useCallback(
389
- (y: number, m: number, d: number) => computeWasteTypesForDay(d, y, m, schedule, exceptions),
390
- [schedule, exceptions]
391
- );
392
-
393
- const getWasteTypesForDay = useCallback(
394
- (day: number) => computeWasteTypesForDay(day, currentYear, currentMonth, schedule, exceptions),
395
- [currentYear, currentMonth, schedule, exceptions]
396
- );
397
-
398
- const monthLabel = useMemo(() => `${MONTHS[currentMonth]} ${currentYear}`, [currentMonth, currentYear]);
399
-
400
- return {
401
- cityId: resolvedCityId,
402
- isLoading,
403
- error,
404
-
405
- currentMonth,
406
- currentYear,
407
- monthLabel,
408
- setCurrentMonth,
409
- setCurrentYear,
410
- goToPrevMonth,
411
- goToNextMonth,
412
-
413
- weekDays,
414
- weeks,
415
-
416
- selectedPlaceId: resolvedPlaceId,
417
-
418
- getWasteTypesForDate,
419
- getWasteTypesForDay,
420
- };
421
- }
1
+ import { useCallback, useMemo, useState } from "react";
2
+ import { useLocal } from "../contexts/LocalContext";
3
+ import { useNextCollection, type WasteCollectionPlace, type WasteType } from "./useNextCollection";
4
+
5
+ export const MONTHS = [
6
+ "Janvier",
7
+ "Février",
8
+ "Mars",
9
+ "Avril",
10
+ "Mai",
11
+ "Juin",
12
+ "Juillet",
13
+ "Août",
14
+ "Septembre",
15
+ "Octobre",
16
+ "Novembre",
17
+ "Décembre",
18
+ ] as const;
19
+
20
+ const DAY_MAPPING: Record<string, number> = {
21
+ Dimanche: 6,
22
+ Lundi: 0,
23
+ Mardi: 1,
24
+ Mercredi: 2,
25
+ Jeudi: 3,
26
+ Vendredi: 4,
27
+ Samedi: 5,
28
+ };
29
+
30
+ export type CollectionScheduleEntry = {
31
+ dayOfWeek: number; // lundi=0 ... dimanche=6
32
+ wasteTypeId: string;
33
+ color: string;
34
+ biweekly?: boolean;
35
+ startDate?: string;
36
+ };
37
+
38
+ export type CalendarException = {
39
+ dateFrom: string;
40
+ dateTo: string;
41
+ wasteTypeId?: string;
42
+ };
43
+
44
+ export type WasteTypeInfoForDay = {
45
+ wasteTypeId: string;
46
+ color: string;
47
+ /** Marqueur utilisé dans l’UI historique: “retour” d’exception */
48
+ isException?: boolean;
49
+ hasActiveException?: boolean;
50
+ };
51
+
52
+ /** Coordonnées d’une case (sans données de collecte). */
53
+ export type CalendarDayCellCore = {
54
+ day: number;
55
+ month: number;
56
+ year: number;
57
+ /** `true` si le jour appartient au mois affiché dans le titre. */
58
+ inCurrentMonth: boolean;
59
+ };
60
+
61
+ /** Case prête à l’affichage : planning global + exceptions **de ce jour uniquement** + collectes du jour. */
62
+ export type CalendarDayCell = CalendarDayCellCore & {
63
+ schedule: CollectionScheduleEntry[];
64
+ exceptions: CalendarException[];
65
+ wasteTypesForDay: WasteTypeInfoForDay[];
66
+ };
67
+
68
+ export type UseCollectionCalendarOptions = {
69
+ /** Si omis, on utilise `LocalContext.selectedCityId`. */
70
+ cityId?: string | null;
71
+ /** Si omis, on utilise `LocalContext.selectedCityId` + 1er lieu disponible. */
72
+ selectedPlaceId?: string | null;
73
+ /** Mois initial (0-11). Par défaut: mois courant. */
74
+ initialMonth?: number;
75
+ /** Année initiale. Par défaut: année courante. */
76
+ initialYear?: number;
77
+ };
78
+
79
+ export type UseCollectionCalendarResult = {
80
+ cityId: string | null;
81
+ isLoading: boolean;
82
+ error: string | null;
83
+
84
+ currentMonth: number;
85
+ currentYear: number;
86
+ monthLabel: string;
87
+ setCurrentMonth: (m: number) => void;
88
+ setCurrentYear: (y: number) => void;
89
+ goToPrevMonth: () => void;
90
+ goToNextMonth: () => void;
91
+
92
+ weekDays: string[];
93
+ /** Semaines complètes (7 colonnes), avec jours du mois précédent / suivant pour remplir la grille. */
94
+ weeks: CalendarDayCell[][];
95
+
96
+ selectedPlaceId: string | null;
97
+
98
+ /** Collectes pour une date précise (utile pour les cases en débordement). */
99
+ getWasteTypesForDate: (year: number, month: number, day: number) => WasteTypeInfoForDay[];
100
+ /** Raccourci : jour du **mois affiché** uniquement (même que `getWasteTypesForDate(currentYear, currentMonth, day)`). */
101
+ getWasteTypesForDay: (day: number) => WasteTypeInfoForDay[];
102
+ };
103
+
104
+ function clampMonth(m: number): number {
105
+ if (!Number.isFinite(m)) return new Date().getMonth();
106
+ return Math.max(0, Math.min(11, Math.floor(m)));
107
+ }
108
+
109
+ /**
110
+ * Parse `dateFrom` / `dateTo` renvoyés par l’API (souvent ISO complet `...T00:00:00.000Z`).
111
+ * Évite `${date}T00:00:00` qui produit une chaîne invalide si la valeur contient déjà `T…`.
112
+ * Extrait `YYYY-MM-DD` et construit une date **locale** à minuit (alignée sur les cases du calendrier).
113
+ */
114
+ function exceptionBoundaryToLocalDate(value: string): Date {
115
+ const trimmed = value.trim();
116
+ const datePart = trimmed.includes("T") ? trimmed.split("T")[0]! : trimmed;
117
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(datePart);
118
+ if (m) {
119
+ return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 0, 0, 0, 0);
120
+ }
121
+ const d = new Date(trimmed);
122
+ if (!Number.isNaN(d.getTime())) {
123
+ d.setHours(0, 0, 0, 0);
124
+ return d;
125
+ }
126
+ return new Date(NaN);
127
+ }
128
+
129
+ /**
130
+ * Grille calendrier : semaines de 7 jours, semaine commençant lundi.
131
+ * Les cases avant le 1er et après le dernier jour du mois utilisent le mois précédent / suivant.
132
+ */
133
+ export function buildCalendarWeeks(year: number, month: number): CalendarDayCellCore[][] {
134
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
135
+ const firstDayOfMonth = new Date(year, month, 1).getDay();
136
+ const mondayOffset = firstDayOfMonth === 0 ? 6 : firstDayOfMonth - 1;
137
+
138
+ const prevMonth = month === 0 ? 11 : month - 1;
139
+ const prevYear = month === 0 ? year - 1 : year;
140
+ const daysInPrevMonth = new Date(year, month, 0).getDate();
141
+
142
+ const cells: CalendarDayCellCore[] = [];
143
+
144
+ for (let i = 0; i < mondayOffset; i += 1) {
145
+ const d = daysInPrevMonth - mondayOffset + 1 + i;
146
+ cells.push({ day: d, month: prevMonth, year: prevYear, inCurrentMonth: false });
147
+ }
148
+
149
+ for (let d = 1; d <= daysInMonth; d += 1) {
150
+ cells.push({ day: d, month, year, inCurrentMonth: true });
151
+ }
152
+
153
+ const nextMonth = month === 11 ? 0 : month + 1;
154
+ const nextYear = month === 11 ? year + 1 : year;
155
+ let nextDay = 1;
156
+ while (cells.length % 7 !== 0) {
157
+ cells.push({ day: nextDay, month: nextMonth, year: nextYear, inCurrentMonth: false });
158
+ nextDay += 1;
159
+ }
160
+
161
+ const weeks: CalendarDayCellCore[][] = [];
162
+ for (let i = 0; i < cells.length; i += 7) {
163
+ weeks.push(cells.slice(i, i + 7));
164
+ }
165
+ return weeks;
166
+ }
167
+
168
+ /**
169
+ * Exceptions qui concernent la date calendaire donnée (aligné sur {@link computeWasteTypesForDay} : [dateFrom, dateTo) ;
170
+ * si dateFrom === dateTo, traité comme une exception d’une seule journée).
171
+ */
172
+ export function exceptionsForCalendarDay(
173
+ day: number,
174
+ year: number,
175
+ month: number,
176
+ allExceptions: CalendarException[]
177
+ ): CalendarException[] {
178
+ const cell = new Date(year, month, day);
179
+ cell.setHours(0, 0, 0, 0);
180
+ const c = cell.getTime();
181
+
182
+ return allExceptions.filter((exc) => {
183
+ const from = exceptionBoundaryToLocalDate(exc.dateFrom);
184
+ const to = exceptionBoundaryToLocalDate(exc.dateTo);
185
+ const f = from.getTime();
186
+ const t = to.getTime();
187
+ if (f === t) {
188
+ return c === f;
189
+ }
190
+ return c >= f && c < t;
191
+ });
192
+ }
193
+
194
+ function enrichCalendarWeeks(
195
+ base: CalendarDayCellCore[][],
196
+ schedule: CollectionScheduleEntry[],
197
+ allExceptions: CalendarException[]
198
+ ): CalendarDayCell[][] {
199
+ return base.map((row) =>
200
+ row.map((cell) => {
201
+ const exceptionsForDay = exceptionsForCalendarDay(cell.day, cell.year, cell.month, allExceptions);
202
+ return {
203
+ ...cell,
204
+ schedule,
205
+ exceptions: exceptionsForDay,
206
+ wasteTypesForDay: computeWasteTypesForDay(
207
+ cell.day,
208
+ cell.year,
209
+ cell.month,
210
+ schedule,
211
+ allExceptions
212
+ ),
213
+ };
214
+ })
215
+ );
216
+ }
217
+
218
+ /** @deprecated Préférer `buildCalendarWeeks` pour une grille avec débordements. */
219
+ export function getDaysInMonth(year: number, month: number): Array<number | null> {
220
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
221
+ const firstDayOfMonth = new Date(year, month, 1).getDay();
222
+ const mondayBasedDay = firstDayOfMonth === 0 ? 6 : firstDayOfMonth - 1;
223
+ const days: Array<number | null> = [];
224
+ for (let i = 0; i < mondayBasedDay; i += 1) days.push(null);
225
+ for (let day = 1; day <= daysInMonth; day += 1) days.push(day);
226
+ return days;
227
+ }
228
+
229
+ function getWeeksSinceStart(startDateStr: string, currentDate: Date): number {
230
+ const startDate = new Date(startDateStr);
231
+ const diffDays = Math.floor((currentDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
232
+ return Math.floor(diffDays / 7);
233
+ }
234
+
235
+ function buildSchedule(
236
+ wasteTypes: WasteType[],
237
+ places: WasteCollectionPlace[],
238
+ selectedPlaceId?: string | null
239
+ ): { schedule: CollectionScheduleEntry[]; exceptions: CalendarException[]; resolvedPlaceId: string | null } {
240
+ const safePlaces = (places || []) as WasteCollectionPlace[];
241
+ if (!safePlaces.length) {
242
+ return { schedule: [], exceptions: [], resolvedPlaceId: null };
243
+ }
244
+
245
+ const selected =
246
+ (selectedPlaceId
247
+ ? safePlaces.find((p) => p.id === selectedPlaceId)
248
+ : undefined) ?? safePlaces[0];
249
+
250
+ const wasteTypeMap: Record<string, { color?: string }> = {};
251
+ (wasteTypes || []).forEach((wt) => {
252
+ if (wt?.id) wasteTypeMap[wt.id] = { color: wt.color };
253
+ });
254
+
255
+ const rawDays = (selected.collectionDays || []) as Array<{
256
+ name: string;
257
+ wasteTypeId: string;
258
+ biweekly?: boolean;
259
+ startDate?: string;
260
+ }>;
261
+
262
+ const schedule: CollectionScheduleEntry[] = [];
263
+ for (const day of rawDays) {
264
+ const dayIndex = DAY_MAPPING[day.name];
265
+ if (dayIndex === undefined) continue;
266
+ const color = wasteTypeMap[day.wasteTypeId]?.color;
267
+ schedule.push({
268
+ dayOfWeek: dayIndex,
269
+ wasteTypeId: day.wasteTypeId,
270
+ color: typeof color === "string" && color.trim().length > 0 ? color : "#7C7C7C",
271
+ biweekly: day.biweekly,
272
+ startDate: day.startDate,
273
+ });
274
+ }
275
+
276
+ const exceptions = ((selected as unknown as { exceptions?: unknown[] })?.exceptions || []) as CalendarException[];
277
+
278
+ return { schedule, exceptions, resolvedPlaceId: selected?.id ?? null };
279
+ }
280
+
281
+ function computeWasteTypesForDay(
282
+ day: number,
283
+ year: number,
284
+ month: number,
285
+ schedule: CollectionScheduleEntry[],
286
+ exceptions: CalendarException[]
287
+ ): WasteTypeInfoForDay[] {
288
+ const date = new Date(year, month, day);
289
+ const dayOfWeek = date.getDay();
290
+ const mondayBasedDay = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
291
+
292
+ const daySchedules = schedule.filter((s) => s.dayOfWeek === mondayBasedDay);
293
+
294
+ const wasteTypes: WasteTypeInfoForDay[] = [];
295
+ for (const s of daySchedules) {
296
+ if (s.biweekly && s.startDate) {
297
+ const weeksSinceStart = getWeeksSinceStart(s.startDate, date);
298
+ if (weeksSinceStart >= 0 && weeksSinceStart % 2 === 0) {
299
+ wasteTypes.push({ wasteTypeId: s.wasteTypeId, color: s.color });
300
+ }
301
+ } else if (!s.biweekly) {
302
+ wasteTypes.push({ wasteTypeId: s.wasteTypeId, color: s.color });
303
+ }
304
+ }
305
+
306
+ // Filtre des exceptions: si l’exception cible un wasteTypeId précis, on ne garde pas ce type pendant la période.
307
+ const filtered: WasteTypeInfoForDay[] = [];
308
+ for (const wt of wasteTypes) {
309
+ const hasActiveException = exceptions.some((exc) => {
310
+ if (exc?.wasteTypeId && exc.wasteTypeId !== wt.wasteTypeId) return false;
311
+ const from = exceptionBoundaryToLocalDate(exc.dateFrom);
312
+ const to = exceptionBoundaryToLocalDate(exc.dateTo);
313
+ return date.getTime() >= from.getTime() && date.getTime() < to.getTime();
314
+ });
315
+ if (!hasActiveException) {
316
+ filtered.push({ ...wt, hasActiveException: false });
317
+ }
318
+ }
319
+
320
+ // “Retour” d’exception: quand on tombe exactement sur dateTo, on peut marquer le type comme exception (UI ✓).
321
+ for (const exc of exceptions) {
322
+ if (!exc?.wasteTypeId) continue;
323
+ const dateToBoundary = exceptionBoundaryToLocalDate(exc.dateTo);
324
+ if (date.getTime() !== dateToBoundary.getTime()) continue;
325
+ const matching = schedule.find((s) => s.wasteTypeId === exc.wasteTypeId);
326
+ if (!matching) continue;
327
+ if (!filtered.some((x) => x.wasteTypeId === matching.wasteTypeId)) {
328
+ filtered.push({
329
+ wasteTypeId: matching.wasteTypeId,
330
+ color: matching.color,
331
+ isException: true,
332
+ hasActiveException: false,
333
+ });
334
+ }
335
+ }
336
+
337
+ return filtered;
338
+ }
339
+
340
+ export function useCollectionCalendar(options: UseCollectionCalendarOptions = {}): UseCollectionCalendarResult {
341
+ const { selectedCityId, selectedPlaceId: localPlaceId } = useLocal();
342
+
343
+ const resolvedCityId = options.cityId ?? selectedCityId ?? null;
344
+ const resolvedPlaceIdOption = options.selectedPlaceId ?? localPlaceId ?? null;
345
+ const today = new Date();
346
+ const initialMonth = clampMonth(options.initialMonth ?? today.getMonth());
347
+ const initialYear = Number.isFinite(options.initialYear) ? (options.initialYear as number) : today.getFullYear();
348
+
349
+ const [currentMonth, setCurrentMonth] = useState<number>(initialMonth);
350
+ const [currentYear, setCurrentYear] = useState<number>(initialYear);
351
+
352
+ const { wasteTypes, wasteCollectionPlaces, isLoading, error } = useNextCollection({
353
+ cityId: resolvedCityId,
354
+ selectedPlaceId: resolvedPlaceIdOption,
355
+ });
356
+
357
+ const { schedule, exceptions, resolvedPlaceId } = useMemo(
358
+ () => buildSchedule(wasteTypes, wasteCollectionPlaces, resolvedPlaceIdOption),
359
+ [wasteTypes, wasteCollectionPlaces, resolvedPlaceIdOption]
360
+ );
361
+
362
+ const goToPrevMonth = useCallback(() => {
363
+ setCurrentMonth((m) => {
364
+ if (m === 0) {
365
+ setCurrentYear((y) => y - 1);
366
+ return 11;
367
+ }
368
+ return m - 1;
369
+ });
370
+ }, []);
371
+
372
+ const goToNextMonth = useCallback(() => {
373
+ setCurrentMonth((m) => {
374
+ if (m === 11) {
375
+ setCurrentYear((y) => y + 1);
376
+ return 0;
377
+ }
378
+ return m + 1;
379
+ });
380
+ }, []);
381
+
382
+ const weeks = useMemo(() => {
383
+ const base = buildCalendarWeeks(currentYear, currentMonth);
384
+ return enrichCalendarWeeks(base, schedule, exceptions);
385
+ }, [currentYear, currentMonth, schedule, exceptions]);
386
+ const weekDays = useMemo(() => ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"], []);
387
+
388
+ const getWasteTypesForDate = useCallback(
389
+ (y: number, m: number, d: number) => computeWasteTypesForDay(d, y, m, schedule, exceptions),
390
+ [schedule, exceptions]
391
+ );
392
+
393
+ const getWasteTypesForDay = useCallback(
394
+ (day: number) => computeWasteTypesForDay(day, currentYear, currentMonth, schedule, exceptions),
395
+ [currentYear, currentMonth, schedule, exceptions]
396
+ );
397
+
398
+ const monthLabel = useMemo(() => `${MONTHS[currentMonth]} ${currentYear}`, [currentMonth, currentYear]);
399
+
400
+ return {
401
+ cityId: resolvedCityId,
402
+ isLoading,
403
+ error,
404
+
405
+ currentMonth,
406
+ currentYear,
407
+ monthLabel,
408
+ setCurrentMonth,
409
+ setCurrentYear,
410
+ goToPrevMonth,
411
+ goToNextMonth,
412
+
413
+ weekDays,
414
+ weeks,
415
+
416
+ selectedPlaceId: resolvedPlaceId,
417
+
418
+ getWasteTypesForDate,
419
+ getWasteTypesForDay,
420
+ };
421
+ }