@propriety/court-calendar 0.0.0

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 (53) hide show
  1. package/.editorconfig +26 -0
  2. package/README.md +0 -0
  3. package/biome.json +302 -0
  4. package/dev/App.tsx +51 -0
  5. package/dev/main.tsx +10 -0
  6. package/index.html +12 -0
  7. package/package.json +54 -0
  8. package/public/vite.svg +1 -0
  9. package/src/_components/CCalendar.css +463 -0
  10. package/src/_components/CCalendar.tsx +726 -0
  11. package/src/_components/List/CalendarList.tsx +288 -0
  12. package/src/_components/Modal/CaseDetails/CaseDetails.tsx +414 -0
  13. package/src/_components/Modal/CaseDetails/EvidenceRow.tsx +83 -0
  14. package/src/_components/Modal/CaseDetails/EvidenceSection.tsx +94 -0
  15. package/src/_components/Modal/CreateEdit/CreateEditCase.tsx +241 -0
  16. package/src/_components/Modal/CreateEdit/DateSelector.tsx +42 -0
  17. package/src/_components/Modal/CreateEdit/EditUserFieldDropdown.tsx +54 -0
  18. package/src/_components/Modal/CreateEdit/EnumDropdown.tsx +54 -0
  19. package/src/_components/Modal/CreateEdit/HearingOfficerDropdown.tsx +48 -0
  20. package/src/_components/Modal/CreateEdit/TextFieldList.tsx +186 -0
  21. package/src/_components/Modal/CreateEdit/ToggleableTextField.tsx +91 -0
  22. package/src/_components/Modal/Modal.css +15 -0
  23. package/src/_components/Modal/Modal.tsx +325 -0
  24. package/src/_components/Modal/ModalActions.tsx +99 -0
  25. package/src/_components/Modal/View/CaseToolbar.tsx +81 -0
  26. package/src/_components/Modal/View/CaseViewer.tsx +237 -0
  27. package/src/_components/Modal/View/DateDetails.tsx +138 -0
  28. package/src/_components/Modal/View/InfoBox.tsx +22 -0
  29. package/src/_components/Modal/View/InfoBoxBtn.css +39 -0
  30. package/src/_components/Modal/View/InfoBoxBtn.tsx +29 -0
  31. package/src/_components/Modal/View/NoticeFileLink.tsx +44 -0
  32. package/src/_components/Shared/FirstSecondChairIcons.tsx +247 -0
  33. package/src/_components/Shared/FormRow.tsx +37 -0
  34. package/src/_components/Shared/MuniDropdown.tsx +94 -0
  35. package/src/_components/Shared/SearchBar.tsx +87 -0
  36. package/src/_components/Toolbar/CaseFilter.tsx +77 -0
  37. package/src/_components/Toolbar/DateTypeFilter.tsx +63 -0
  38. package/src/_components/Toolbar/HearingTypeFilter.tsx +63 -0
  39. package/src/_components/Toolbar/Toolbar.tsx +159 -0
  40. package/src/_components/Toolbar/UserFilter.tsx +105 -0
  41. package/src/_components/Toolbar/ViewFilter.tsx +48 -0
  42. package/src/helpers/cache.ts +89 -0
  43. package/src/helpers/cases.ts +79 -0
  44. package/src/helpers/courtDates.ts +139 -0
  45. package/src/helpers/formatter.ts +16 -0
  46. package/src/helpers/munis.ts +44 -0
  47. package/src/helpers/people.ts +46 -0
  48. package/src/index.ts +2 -0
  49. package/src/types.ts +129 -0
  50. package/tsconfig.app.json +32 -0
  51. package/tsconfig.json +4 -0
  52. package/tsconfig.node.json +30 -0
  53. package/vite.config.ts +27 -0
@@ -0,0 +1,726 @@
1
+ // #region imports
2
+ import FullCalendar from '@fullcalendar/react';
3
+ import dayGridPlugin from '@fullcalendar/daygrid';
4
+ import timeGridPlugin from '@fullcalendar/timegrid';
5
+ import interactionPlugin, { type DateClickArg } from '@fullcalendar/interaction';
6
+ import listPlugin from '@fullcalendar/list';
7
+ import { useEffect, useRef, useState, useMemo } from 'react';
8
+ import './CCalendar.css';
9
+ import Toolbar from './Toolbar/Toolbar';
10
+ import { Calendar, type EventMountArg, type EventSourceInput, type EventClickArg } from '@fullcalendar/core/index.js';
11
+ import {
12
+ HearingType,
13
+ Lifecycle,
14
+ ModalMode,
15
+ SourceType,
16
+ Status,
17
+ type CalendarFilterCtx,
18
+ type Case,
19
+ type CourtDate,
20
+ } from '@/types';
21
+ import { Tooltip } from 'react-tooltip';
22
+ import Modal from './Modal/Modal';
23
+ import { getAllDates, isVillageDate, updateCourtDate } from '@/helpers/courtDates';
24
+ import { getTownshipName } from '@/helpers/munis';
25
+ import Typography from '@mui/material/Typography';
26
+ import Stack from '@mui/material/Stack';
27
+ import {
28
+ fetchAllCasesPaginated,
29
+ fetchCasesByCourtDate,
30
+ isCaseMissingEvidence,
31
+ isCaseSettled,
32
+ searchByCaseTerm,
33
+ } from '@/helpers/cases';
34
+ import {
35
+ getCachedCases,
36
+ removeCasesCache,
37
+ updateCasesCache,
38
+ getCourtDatesCache,
39
+ setCourtDatesCache,
40
+ } from '@/helpers/cache';
41
+ import CalendarList from './List/CalendarList';
42
+ // #endregion
43
+ export default function CCalendar({ apiKey, activeUser }: { apiKey: string; activeUser: number }) {
44
+ // #region state
45
+ const [calendarApi, setCalendarApi] = useState<Calendar | null>(null);
46
+ const [courtDates, setCourtDates] = useState<Array<CourtDate>>([]);
47
+ const [events, setEvents] = useState<EventSourceInput>([]);
48
+ const [eventHash, setEventHash] = useState<string>('');
49
+ const [modalIsOpen, setIsOpen] = useState(false);
50
+ const [selectedCourtDate, setSelectedCourtDate] = useState<CourtDate | null>(null);
51
+ const calendarRef = useRef<FullCalendar | null>(null);
52
+ const [filterCtx, setFilterCtx] = useState<CalendarFilterCtx>({
53
+ showInPerson: true,
54
+ showVirtual: true,
55
+ showUnknown: true,
56
+ showOnlyAssignedToUser: null,
57
+ showUploadDeadlines: false,
58
+ showAdjournmentDates: true,
59
+ showCourtDates: true,
60
+ searchTerm: '',
61
+ municode: null,
62
+ user: null,
63
+ showOnlyUnsettled: false,
64
+ showOnlyWithoutEvidence: false,
65
+ showOnlyUnreviewed: false,
66
+ });
67
+ const [currentView, setCurrentView] = useState('dayGridMonth');
68
+ const [currentDate, setCurrentDate] = useState<Date>(new Date());
69
+ const [modalMode, setModalMode] = useState<ModalMode>(ModalMode.DETAILS);
70
+ const [scrollbarClicked, setScrollbarClicked] = useState(false);
71
+ const [allCases, setAllCases] = useState<Record<string, Case[]>>({});
72
+ const [selectedCases, setSelectedCases] = useState<Case[]>([]);
73
+ const [isFetchingCases, setIsFetchingCases] = useState<boolean>(false);
74
+ const [searchedDateIDs, setSearchedDateIDs] = useState<Array<number>>([]);
75
+ // Track previously rendered event IDs to avoid reanimating unchanged events
76
+ const prevEventIdsRef = useRef<Set<string>>(new Set());
77
+ // Track previous view to detect actual view changes vs calendarApi remounts
78
+ const prevViewRef = useRef<string>(currentView);
79
+ // #endregion
80
+
81
+ // #region initializer
82
+ async function getAllCourtDates(apiKey: string) {
83
+ // Try to load from IndexedDB cache
84
+ const cachedDates = await getCourtDatesCache();
85
+ if (cachedDates) {
86
+ setCourtDates(cachedDates);
87
+ } else {
88
+ getAllDates(apiKey)
89
+ .then(async (dates) => {
90
+ setCourtDates(dates);
91
+ await setCourtDatesCache(dates);
92
+ })
93
+ .catch((error) => {
94
+ console.error('Failed to fetch court dates:', error);
95
+ });
96
+ }
97
+ }
98
+
99
+ useEffect(() => {
100
+ getAllCourtDates(apiKey);
101
+ }, []);
102
+ // #endregion
103
+
104
+ // #region callbacks
105
+ function updateCourtDateInMemory(updatedDate: CourtDate, del?: boolean) {
106
+ setCourtDates((prevDates) => {
107
+ const index = prevDates.findIndex((date) => date.CourtDateID === updatedDate.CourtDateID);
108
+ let newDates;
109
+ if (index !== -1) {
110
+ newDates = [...prevDates];
111
+ if (del) {
112
+ newDates.splice(index, 1);
113
+ } else {
114
+ newDates[index] = updatedDate;
115
+ }
116
+ } else {
117
+ newDates = [...prevDates, updatedDate];
118
+ }
119
+ // Defer cache update to not block render
120
+ queueMicrotask(() => setCourtDatesCache(newDates));
121
+ return newDates;
122
+ });
123
+ }
124
+
125
+ function handleUpdateChair(courtDateId: number, position: 'first' | 'second', userId: number | null) {
126
+ const courtDate = courtDates.find((d) => d.CourtDateID === courtDateId);
127
+ if (!courtDate) return;
128
+
129
+ const updatedData: Partial<CourtDate> = position === 'first' ? { FirstChair: userId } : { SecondChair: userId };
130
+ const updatedCourtDate = { ...courtDate, ...updatedData };
131
+
132
+ // Optimistic update - update UI immediately
133
+ updateCourtDateInMemory(updatedCourtDate);
134
+
135
+ // Then persist to API in background
136
+ updateCourtDate(courtDateId, updatedData, apiKey).then((success) => {
137
+ if (!success) {
138
+ // Revert on failure
139
+ updateCourtDateInMemory(courtDate);
140
+ console.error('Failed to update chair assignment, reverted');
141
+ }
142
+ });
143
+ }
144
+
145
+ // #endregion
146
+
147
+ // #region case fetching
148
+ // there will be 3 sources for data, in order of priority:
149
+ // 1. memory (allCases)
150
+ // 2. localStorage cache
151
+ // 3. fetch from API
152
+ // whenever we fetch from a place lower in priority, we update the higher places accordingly
153
+
154
+ // FETCHING ALL:
155
+ // fetching all cases for all court dates at once can be heavy, so we will fetch individually as needed
156
+ // when we fetch all cases, we will always check memory first since it is likely up to date and free
157
+ // if not in memory, we will check localStorage cache next
158
+ // if its in cache and not expired, we will use that
159
+ // if not in cache or expired, we will fetch from API
160
+
161
+ // FETCHING INDIVIDUAL:
162
+ // when fetching cases for a single court date (when clicked), we will always fetch from API to ensure up to date data
163
+ // but we will still update memory and cache accordingly
164
+ async function getCases(
165
+ courtDateIDs: number[],
166
+ skipMemory: boolean,
167
+ skipCache: boolean,
168
+ ): Promise<Record<string, Case[]>> {
169
+ console.log(`Looking for ${courtDateIDs.length} court dates' cases...`);
170
+ setIsFetchingCases(true);
171
+ const memoryCasesByCourtDate: Record<string, Case[]> = {};
172
+ const memoryMissingIDs: string[] = [];
173
+
174
+ const cachedCasesByCourtDate: Record<string, Case[]> = {};
175
+ const cachedMissingIDs: string[] = [];
176
+
177
+ const fetchedCasesByCourtDate: Record<string, Case[]> = {};
178
+ const fetchedMissingIDs: string[] = [];
179
+
180
+ // try memory
181
+ courtDateIDs.forEach((id) => {
182
+ const key = id.toString();
183
+ if (!skipMemory && allCases[key]) {
184
+ memoryCasesByCourtDate[key] = allCases[key];
185
+ } else {
186
+ memoryMissingIDs.push(key);
187
+ }
188
+ });
189
+
190
+ // try cache for missing memory cases
191
+ console.log(
192
+ `Found ${memoryMissingIDs.length} missing from memory, checking cache for ${memoryMissingIDs.length} cases...`,
193
+ );
194
+ // Use for...of to await sequentially so cachedMissingIDs is correct
195
+ for (const id of memoryMissingIDs) {
196
+ const cached = !skipCache ? await getCachedCases(id.toString()) : null;
197
+ if (cached) {
198
+ cachedCasesByCourtDate[id] = cached;
199
+ } else {
200
+ cachedMissingIDs.push(id);
201
+ }
202
+ }
203
+
204
+ await addPartialCasesToMemoryAndCache(cachedCasesByCourtDate, true); // skip cache since these are from cache
205
+
206
+ // fetch from API for missing cache cases
207
+ console.log(
208
+ `Found ${cachedMissingIDs.length} missing from cache, fetching ${cachedMissingIDs.length} cases from API...`,
209
+ );
210
+ // if all more than 10 are missing, call the batched fetch function
211
+ if (cachedMissingIDs.length > 10) {
212
+ for await (const fetched of fetchAllCasesPaginated(apiKey)) {
213
+ Object.entries(fetched).forEach(([courtDateIDStr, cases]) => {
214
+ if (cachedMissingIDs.includes(courtDateIDStr)) {
215
+ fetchedCasesByCourtDate[courtDateIDStr] = cases;
216
+ }
217
+ });
218
+ // need to update memory and stuff within the loop since this is a generator
219
+ await addPartialCasesToMemoryAndCache(fetchedCasesByCourtDate, false);
220
+ }
221
+ } else {
222
+ // fetch individually
223
+ await Promise.all(
224
+ cachedMissingIDs.map(async (id) => {
225
+ const fetched = await fetchCasesByCourtDate(id, apiKey);
226
+ if (fetched && fetched.length > 0) {
227
+ fetchedCasesByCourtDate[id] = fetched;
228
+ } else {
229
+ fetchedMissingIDs.push(id);
230
+ }
231
+ await addPartialCasesToMemoryAndCache(fetchedCasesByCourtDate, false);
232
+ }),
233
+ );
234
+ }
235
+
236
+ // if there are any missing IDs after all that, throw an error
237
+ if (fetchedMissingIDs.length > 0) {
238
+ console.error('Failed to fetch cases for court date IDs:', fetchedMissingIDs);
239
+ }
240
+ setIsFetchingCases(false);
241
+ // log number of cases fetched from each source
242
+ console.log(
243
+ `Cases fetched - Memory: ${Object.keys(memoryCasesByCourtDate).length}, Cache: ${Object.keys(cachedCasesByCourtDate).length}, Fetched: ${Object.keys(fetchedCasesByCourtDate).length}`,
244
+ );
245
+
246
+ // Return combined cases from all sources
247
+ return { ...memoryCasesByCourtDate, ...cachedCasesByCourtDate, ...fetchedCasesByCourtDate };
248
+ }
249
+
250
+ // add partial cases to memory without overwriting existing ones
251
+ async function addPartialCasesToMemoryAndCache(cases: Record<string, Case[]>, skipCache: boolean) {
252
+ // Update memory with any newly fetched or cached cases
253
+ console.log(
254
+ `Adding ${Object.keys(cases).length} partial cases to ${skipCache ? 'memory only' : 'memory and cache'}...`,
255
+ );
256
+
257
+ setAllCases((prev) => {
258
+ const newCases = { ...prev };
259
+ Object.entries(cases).forEach(([courtDateID, cases]) => {
260
+ newCases[courtDateID.toString()] = cases;
261
+ });
262
+
263
+ return newCases;
264
+ });
265
+
266
+ // Update cache with any newly fetched cases
267
+ if (skipCache) return;
268
+ await Promise.all(Object.entries(cases).map(([courtDateID, cases]) => updateCasesCache(courtDateID, cases)));
269
+ }
270
+
271
+ async function deleteCaseMemoryAndCache(courtDateID: string) {
272
+ setAllCases((prev) => {
273
+ const newCases = { ...prev };
274
+ delete newCases[courtDateID];
275
+ return newCases;
276
+ });
277
+ await removeCasesCache(courtDateID);
278
+ }
279
+
280
+ // ALL DATES
281
+ useEffect(() => {
282
+ if (courtDates.length === 0) return;
283
+ // fetch cases for all court dates in memory and cache
284
+ (async () => {
285
+ await getCases(
286
+ courtDates.map((cd) => cd.CourtDateID),
287
+ false, // dont skip memory
288
+ false, // dont skip cache
289
+ );
290
+ })();
291
+ }, [courtDates]);
292
+
293
+ // SELECTED DATE
294
+ useEffect(() => {
295
+ if (!selectedCourtDate) return;
296
+ // Skip fetch for new court dates (negative ID means it hasn't been created yet)
297
+ if (selectedCourtDate.CourtDateID < 0) {
298
+ setSelectedCases([]);
299
+ return;
300
+ }
301
+ // fetch cases for selected court date
302
+ (async () => {
303
+ const fetchedCases = await getCases(
304
+ [selectedCourtDate.CourtDateID],
305
+ true, // skip memory
306
+ true, // skip cache
307
+ );
308
+ // Use the returned cases directly to avoid stale closure
309
+ setSelectedCases(fetchedCases[selectedCourtDate.CourtDateID.toString()] || []);
310
+ })();
311
+ }, [selectedCourtDate]);
312
+
313
+ // #endregion
314
+
315
+ // #region click event handlers
316
+ useEffect(() => {
317
+ // --- Scrollbar click detection ---
318
+ const handler = (e: MouseEvent) => {
319
+ // Only check for left click
320
+ if (e.button !== 0) return;
321
+ // Look for scrollable event area
322
+ const target = e.target as HTMLElement;
323
+ if (target && target.classList.contains('fc-daygrid-day-events')) {
324
+ const { offsetWidth, clientWidth, offsetHeight, clientHeight } = target;
325
+ // Vertical scrollbar
326
+ console.log(offsetWidth, clientWidth, offsetHeight, clientHeight);
327
+ if (offsetWidth > clientWidth - 10) {
328
+ const scrollbarX = clientWidth;
329
+ if (e.offsetX >= scrollbarX - 10) {
330
+ setScrollbarClicked(true);
331
+ return;
332
+ }
333
+ }
334
+ }
335
+ setScrollbarClicked(false);
336
+ };
337
+ document.addEventListener('mousedown', handler, true);
338
+
339
+ return () => {
340
+ document.removeEventListener('mousedown', handler, true);
341
+ };
342
+ }, []);
343
+
344
+ function handleDateClick(dateClickInfo: DateClickArg) {
345
+ if (scrollbarClicked) {
346
+ setScrollbarClicked(false);
347
+ return;
348
+ }
349
+ console.log('Date clicked:', dateClickInfo.dateStr);
350
+ setCurrentView('listDay');
351
+ setCurrentDate(dateClickInfo.date);
352
+ if (calendarApi) {
353
+ calendarApi.gotoDate(dateClickInfo.date);
354
+ }
355
+ }
356
+
357
+ function createCourtDate(dateClickInfo: Date) {
358
+ console.log('Date clicked:', dateClickInfo?.toISOString());
359
+ setSelectedCourtDate({
360
+ CourtDateID: -1,
361
+ CourtDate: dateClickInfo || null,
362
+ MuniCode: '',
363
+ Lifecycle: Lifecycle.SCHEDULED,
364
+ CourtCases: 0,
365
+ UploadDeadline: null,
366
+ HearingTime: '',
367
+ HearingLink: null,
368
+ Source: SourceType.MANUAL,
369
+ Type: HearingType.UNKNOWN,
370
+ FirstChair: null,
371
+ SecondChair: null,
372
+ HearingOfficer: null,
373
+ EnterDate: new Date(),
374
+ LastUpdateDate: new Date(),
375
+ AdjournmentDate: null,
376
+ IsAdjourned: false,
377
+ Notes: null,
378
+ NoticeFile: null,
379
+ });
380
+ setModalMode(ModalMode.CREATE);
381
+ setIsOpen(true);
382
+ }
383
+
384
+ function handleEventClick(clickInfo: EventClickArg) {
385
+ const courtDate = clickInfo.event.extendedProps as CourtDate;
386
+ clickCallback(courtDate);
387
+ }
388
+
389
+ function clickCallback(date: CourtDate) {
390
+ setSelectedCourtDate(date);
391
+ setIsOpen(true);
392
+ }
393
+ // #endregion
394
+
395
+ // #region search
396
+ useEffect(() => {
397
+ setSearchedDateIDs([]);
398
+ if (filterCtx.searchTerm.trim() === '') return;
399
+ (async () => {
400
+ for await (const result of searchByCaseTerm(filterCtx.searchTerm, apiKey)) {
401
+ setSearchedDateIDs((prev) => [...prev, ...result]);
402
+ }
403
+ })();
404
+ }, [filterCtx.searchTerm]);
405
+ // #endregion
406
+
407
+ // #region filtering
408
+ const filteredDates = useMemo(() => {
409
+ return courtDates
410
+ .filter((date) => {
411
+ if (searchedDateIDs.length === 0 || filterCtx.searchTerm.trim() === '') return true;
412
+ return searchedDateIDs.includes(date.CourtDateID);
413
+ })
414
+ .filter((date) => {
415
+ if (!filterCtx.showInPerson) {
416
+ return date.Type !== HearingType.IN_PERSON;
417
+ }
418
+ return true;
419
+ })
420
+ .filter((date) => {
421
+ if (!filterCtx.showVirtual) {
422
+ return date.Type !== HearingType.VIRTUAL;
423
+ }
424
+ return true;
425
+ })
426
+ .filter((date) => {
427
+ if (!filterCtx.showUnknown) {
428
+ return date.Type !== HearingType.UNKNOWN && date.Type !== null;
429
+ }
430
+ return true;
431
+ })
432
+ .filter((date) => {
433
+ if (filterCtx.showOnlyAssignedToUser !== null) {
434
+ return (
435
+ date.FirstChair === filterCtx.showOnlyAssignedToUser ||
436
+ date.SecondChair === filterCtx.showOnlyAssignedToUser
437
+ );
438
+ }
439
+ return true;
440
+ })
441
+ .filter((date) => {
442
+ if (filterCtx.municode) {
443
+ return date.MuniCode === filterCtx.municode;
444
+ }
445
+ return true;
446
+ })
447
+ .filter((date) => {
448
+ if (filterCtx.showOnlyUnsettled) {
449
+ for (const c of allCases[date.CourtDateID.toString()] || []) {
450
+ if (!isCaseSettled(c, isVillageDate(date.MuniCode))) {
451
+ return true; // has at least one unsettled case
452
+ }
453
+ }
454
+ return false; // all cases are settled, exclude
455
+ }
456
+ return true;
457
+ })
458
+ .filter((date) => {
459
+ if (filterCtx.showOnlyWithoutEvidence) {
460
+ for (const c of allCases[date.CourtDateID.toString()] || []) {
461
+ if (isCaseMissingEvidence(c)) {
462
+ return true; // has at least one case missing evidence
463
+ }
464
+ }
465
+ return false; // all cases have evidence, exclude
466
+ }
467
+ return true;
468
+ })
469
+ .filter((date) => {
470
+ if (filterCtx.showOnlyUnreviewed) {
471
+ for (const c of allCases[date.CourtDateID.toString()] || []) {
472
+ if (!c.DateCompleted) {
473
+ return true; // has at least one unreviewed case
474
+ }
475
+ }
476
+ return false; // all cases reviewed, exclude
477
+ }
478
+ return true;
479
+ });
480
+ }, [courtDates, filterCtx, activeUser, allCases, searchedDateIDs]);
481
+
482
+ const cdates = useMemo(() => {
483
+ if (!filterCtx.showCourtDates) return [];
484
+ return filteredDates
485
+ .filter((date) => date.CourtDate !== null)
486
+ .filter((date) => !date.IsAdjourned)
487
+ .map((date) => ({
488
+ id: date.CourtDateID.toString(),
489
+ title: `${getTownshipName(date.MuniCode)} (${date.MuniCode}) x${date.CourtCases}`,
490
+ start: date.CourtDate,
491
+ allDay: false,
492
+ extendedProps: date,
493
+ classNames: ['courtdate-event', date.Type?.toString().toLowerCase() || 'unknown'],
494
+ }));
495
+ }, [filteredDates, filterCtx]);
496
+
497
+ const deadlines = useMemo(() => {
498
+ if (!filterCtx.showUploadDeadlines) return [];
499
+ return filteredDates
500
+ .filter((date) => date.UploadDeadline !== null)
501
+ .map((date) => ({
502
+ id: `deadline-${date.CourtDateID}`,
503
+ title: `Upload ${getTownshipName(date.MuniCode)} (${date.MuniCode})`,
504
+ start: date.UploadDeadline!,
505
+ allDay: true,
506
+ extendedProps: date,
507
+ classNames: [
508
+ 'deadline-event',
509
+ date.Type?.toString().toLowerCase() || 'unknown',
510
+ date.IsAdjourned && !date.AdjournmentDate ? 'adjourned-no-date' : '',
511
+ date.Lifecycle === Lifecycle.UPLOADED
512
+ ? 'status-uploaded'
513
+ : date.Lifecycle === Lifecycle.ASSIGNED
514
+ ? 'status-assigned'
515
+ : 'status-unknown',
516
+ ],
517
+ }));
518
+ }, [filteredDates, filterCtx]);
519
+
520
+ const adates = useMemo(() => {
521
+ if (!filterCtx.showAdjournmentDates) return [];
522
+ return filteredDates
523
+ .filter((date) => date.IsAdjourned)
524
+ .map((date) => ({
525
+ id: `adjournment-${date.CourtDateID}`,
526
+ title: `Adjournment - ${date.MuniCode}`,
527
+ start: date.AdjournmentDate ?? date.CourtDate,
528
+ allDay: false,
529
+ extendedProps: date,
530
+ classNames: [
531
+ 'adjournment-event',
532
+ date.Type?.toString().toLowerCase() || 'unknown',
533
+ date.IsAdjourned && !date.AdjournmentDate ? 'adjourned-no-date' : '',
534
+ ],
535
+ }));
536
+ }, [filteredDates, filterCtx]);
537
+
538
+ useEffect(() => {
539
+ if (modalIsOpen) {
540
+ return;
541
+ }
542
+ setEvents([...cdates, ...deadlines, ...adates]);
543
+ setEventHash(JSON.stringify([...cdates, ...deadlines, ...adates]));
544
+ }, [cdates, deadlines, adates, modalIsOpen]);
545
+ // #endregion
546
+
547
+ // #region calendar view management
548
+ const handleCalendarRef = (el: FullCalendar | null) => {
549
+ if (el) {
550
+ calendarRef.current = el;
551
+ setCalendarApi(el.getApi() as Calendar);
552
+ }
553
+ };
554
+
555
+ useEffect(() => {
556
+ if (currentView === 'tableView') {
557
+ if (calendarApi) {
558
+ calendarApi.changeView('dayGridMonth');
559
+ }
560
+ prevViewRef.current = currentView;
561
+ return;
562
+ }
563
+ if (calendarApi) {
564
+ calendarApi.changeView(currentView);
565
+ }
566
+ // Only reset to today when actually switching TO dayGridMonth, not on calendarApi remount
567
+ if (currentView === 'dayGridMonth' && prevViewRef.current !== 'dayGridMonth') {
568
+ setCurrentDate(new Date());
569
+ if (calendarApi) {
570
+ calendarApi.gotoDate(new Date());
571
+ }
572
+ }
573
+ prevViewRef.current = currentView;
574
+ }, [currentView, calendarApi]);
575
+ // #endregion
576
+
577
+ // #region Event Mounting (Tooltips & Animations)
578
+ function handleEventMount(info: EventMountArg) {
579
+ // biome-ignore lint/suspicious/noExplicitAny: i dont know the type
580
+ let newVisibleEvents: any[] = [];
581
+ let newEventIndex = 0;
582
+ let viewStart: Date | null = null;
583
+ let viewEnd: Date | null = null;
584
+ if (calendarApi) {
585
+ viewStart = calendarApi.view.activeStart;
586
+ viewEnd = calendarApi.view.activeEnd;
587
+ }
588
+
589
+ // Helper to check if event is visible in current view
590
+ // biome-ignore lint/suspicious/noExplicitAny: i dont know the type
591
+ function isEventVisible(ev: any) {
592
+ if (!viewStart || !viewEnd) return true;
593
+ const evStart = new Date(ev.start);
594
+ // For allDay events, treat as visible if any overlap
595
+ if (ev.allDay) {
596
+ return evStart >= viewStart && evStart < viewEnd;
597
+ }
598
+ return evStart >= viewStart && evStart < viewEnd;
599
+ }
600
+
601
+ if (Array.isArray(events)) {
602
+ // Only consider NEW events (not previously rendered) for animation delay calculation
603
+ newVisibleEvents = events.filter(isEventVisible).filter((e) => {
604
+ const id = e.id?.toString();
605
+ return id && !prevEventIdsRef.current.has(id);
606
+ });
607
+ newEventIndex = newVisibleEvents.findIndex((e) => e.id === info.event.id);
608
+ }
609
+
610
+ // Only animate if this event is new (not in previous set)
611
+ const eventId = info.event.id?.toString();
612
+ if (eventId && !prevEventIdsRef.current.has(eventId)) {
613
+ info.el.classList.add('pop-in-ccalendar-event');
614
+ info.el.style.animationDelay = `${30 * (newEventIndex >= 0 ? newEventIndex : 0)}ms`;
615
+ } else {
616
+ info.el.classList.remove('pop-in-ccalendar-event');
617
+ info.el.style.animationDelay = '0ms';
618
+ }
619
+
620
+ // Tooltip logic
621
+ const tooltipContent = `
622
+ <div><strong>${info.event.title}</strong><br/>
623
+ <em>Court Date:</em> ${info.event.start?.toLocaleString()}<br/>
624
+ ${info.event.extendedProps.IsAdjourned && !info.event.extendedProps.AdjournmentDate ? '<strong>Adjourned - No Adjournment Date Set!</strong><br/>' : ''}
625
+ <em>Upload Deadline:</em> ${info.event.extendedProps.UploadDeadline}<br/>
626
+ <em>Court Cases:</em> ${info.event.extendedProps.CourtCases}<br/>
627
+ <em>Status:</em> ${info.event.extendedProps.Status}<br />
628
+ <em>First Chair:</em> ${info.event.extendedProps.FirstChair ?? 'Unassigned'}<br/>
629
+ <em>Click for more details</em>
630
+ </div>
631
+ `;
632
+ info.el.setAttribute('data-tooltip-id', 'event-tooltip');
633
+ info.el.setAttribute('data-tooltip-html', tooltipContent);
634
+ }
635
+ // Update the set of visible event IDs after each render of events, but defer so eventDidMount runs first
636
+ useEffect(() => {
637
+ if (Array.isArray(events)) {
638
+ const ids = new Set(events.map((e: any) => e.id?.toString()).filter(Boolean));
639
+ // Defer update so eventDidMount runs first
640
+ const timeout = setTimeout(() => {
641
+ prevEventIdsRef.current = ids;
642
+ }, 0);
643
+ return () => clearTimeout(timeout);
644
+ }
645
+ }, [events, eventHash]);
646
+ // #endregion
647
+
648
+ // #region render
649
+ return (
650
+ <div id='ccalendar-container'>
651
+ <Toolbar
652
+ calendarApi={calendarApi}
653
+ filterCtx={filterCtx}
654
+ setFilterCtx={setFilterCtx}
655
+ handleCreateClick={() => createCourtDate(calendarApi?.getDate() || currentDate || new Date())}
656
+ currentView={currentView}
657
+ setCurrentView={setCurrentView}
658
+ currentDate={currentDate}
659
+ setCurrentDate={setCurrentDate}
660
+ activeUser={activeUser}
661
+ isFetchingCases={isFetchingCases}
662
+ />
663
+ {currentView === 'listDay' && (
664
+ <Stack spacing={2} direction={'column'} px={1} mb={1}>
665
+ {Object.values(events)
666
+ .filter((event) => new Date(event.start).toDateString() === currentDate.toDateString())
667
+ .filter((event) => event.extendedProps.Notes && event.extendedProps.Notes !== '')
668
+ .map((event) => (
669
+ <Typography key={event.id}>{`${event.title} - ${event.extendedProps.Notes}`}</Typography>
670
+ ))}
671
+ </Stack>
672
+ )}
673
+ {currentView !== 'tableView' && (
674
+ <FullCalendar
675
+ plugins={[dayGridPlugin, timeGridPlugin, listPlugin, interactionPlugin]}
676
+ key={eventHash}
677
+ initialView={currentView}
678
+ initialDate={currentDate}
679
+ height='auto'
680
+ dayCellClassNames={['month-day-cell']}
681
+ viewClassNames={['court-calendar']}
682
+ allDayClassNames={['day-cell-allday']}
683
+ dayHeaderClassNames={['day-header']}
684
+ eventClassNames={['court-event']}
685
+ headerToolbar={false}
686
+ ref={handleCalendarRef}
687
+ events={events}
688
+ eventClick={handleEventClick}
689
+ dateClick={handleDateClick}
690
+ datesSet={(dateInfo) => setCurrentDate(dateInfo.start)}
691
+ eventDidMount={(info) => {
692
+ handleEventMount(info);
693
+ }}
694
+ />
695
+ )}
696
+ {currentView === 'tableView' && (
697
+ <CalendarList
698
+ filteredDates={filteredDates}
699
+ setSelectedDate={clickCallback}
700
+ currentDate={currentDate}
701
+ onUpdateChair={handleUpdateChair}
702
+ allCases={allCases}
703
+ />
704
+ )}
705
+ <Modal
706
+ modalIsOpen={modalIsOpen}
707
+ modalMode={modalMode}
708
+ setModalMode={setModalMode}
709
+ setIsOpen={setIsOpen}
710
+ selectedCourtDate={selectedCourtDate}
711
+ updateCourtDateInMemory={updateCourtDateInMemory}
712
+ filterCtx={filterCtx}
713
+ setFilterCtx={setFilterCtx}
714
+ selectedCases={selectedCases}
715
+ updateCases={(cases) => {
716
+ addPartialCasesToMemoryAndCache(cases, false);
717
+ }}
718
+ deleteCases={deleteCaseMemoryAndCache}
719
+ isFetchingCases={isFetchingCases}
720
+ apiKey={apiKey}
721
+ />
722
+ <Tooltip id='event-tooltip' className='cc-tooltip' />
723
+ </div>
724
+ );
725
+ // #endregion
726
+ }