@propriety/court-calendar 1.0.131 → 1.0.135

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 (35) hide show
  1. package/C/357/200/272UsersOptiplex-BFHNAppDataLocalTempreview_diff.txt +1245 -0
  2. package/dist/__tests__/hooks/UseCaseData.test.d.ts +1 -0
  3. package/dist/_components/Modal/DateEdit/CreateEditCase.d.ts +2 -1
  4. package/dist/constants.d.ts +2 -0
  5. package/dist/helpers/CalEvent.d.ts +2 -0
  6. package/dist/helpers/api/cases.d.ts +1 -1
  7. package/dist/helpers/api/courtDates.d.ts +1 -1
  8. package/dist/helpers/cache.d.ts +3 -3
  9. package/dist/helpers/filtering.d.ts +14 -4
  10. package/dist/hooks/UseCourtDates.d.ts +1 -0
  11. package/dist/index.mjs +7707 -7561
  12. package/dist/types.d.ts +1 -0
  13. package/package.json +1 -1
  14. package/src/__tests__/filtering.test.ts +130 -24
  15. package/src/__tests__/fixtures.ts +1 -0
  16. package/src/__tests__/helpers/CalEvent.test.ts +6 -4
  17. package/src/__tests__/helpers/api/courtDates.test.ts +6 -6
  18. package/src/__tests__/hooks/UseCaseData.test.ts +66 -0
  19. package/src/__tests__/hooks/UseModalActions.test.ts +3 -3
  20. package/src/_components/CCalendar.tsx +5 -3
  21. package/src/_components/Modal/CaseDetails/CaseDetails.tsx +50 -12
  22. package/src/_components/Modal/DateDetails/CaseViewer.tsx +25 -0
  23. package/src/_components/Modal/DateDetails/NoticeFileLink.tsx +1 -1
  24. package/src/_components/Modal/DateEdit/CreateEditCase.tsx +5 -5
  25. package/src/constants.ts +3 -0
  26. package/src/helpers/CalEvent.ts +37 -25
  27. package/src/helpers/api/cases.ts +4 -2
  28. package/src/helpers/api/courtDates.ts +16 -8
  29. package/src/helpers/cache.ts +23 -12
  30. package/src/helpers/filtering.ts +30 -35
  31. package/src/hooks/UseCalendarEvents.ts +4 -2
  32. package/src/hooks/UseCaseData.ts +124 -70
  33. package/src/hooks/UseCourtDates.ts +120 -16
  34. package/src/hooks/UseModalActions.ts +27 -7
  35. package/src/types.ts +2 -0
@@ -0,0 +1,1245 @@
1
+ diff --git a/.gitignore b/.gitignore
2
+ index d907ddc..b612261 100644
3
+ --- a/.gitignore
4
+ +++ b/.gitignore
5
+ @@ -29,3 +29,5 @@ dist-ssr
6
+ */.env
7
+ */.env.*.local
8
+ */.env.local
9
+ +
10
+ +nul
11
+ diff --git a/src/__tests__/hooks/UseModalActions.test.ts b/src/__tests__/hooks/UseModalActions.test.ts
12
+ index e2059a0..0048701 100644
13
+ --- a/src/__tests__/hooks/UseModalActions.test.ts
14
+ +++ b/src/__tests__/hooks/UseModalActions.test.ts
15
+ @@ -129,6 +129,8 @@ describe('handleSave — EDIT mode', () => {
16
+ expect.anything(),
17
+ MOCK_USER_ID,
18
+ DateType.SCAR,
19
+ + undefined,
20
+ + undefined,
21
+ );
22
+ });
23
+
24
+ @@ -143,6 +145,8 @@ describe('handleSave — EDIT mode', () => {
25
+ ['SC-007'],
26
+ expect.anything(),
27
+ expect.anything(),
28
+ + undefined,
29
+ + undefined,
30
+ );
31
+ });
32
+
33
+ @@ -229,6 +233,7 @@ describe('handleSave — CREATE mode (delegates to handleCreate)', () => {
34
+ '10:00',
35
+ undefined,
36
+ DateType.SCAR,
37
+ + undefined,
38
+ );
39
+ });
40
+
41
+ diff --git a/src/_components/Modal/DateDetails/DateDetails.tsx b/src/_components/Modal/DateDetails/DateDetails.tsx
42
+ index 0b65580..b369958 100644
43
+ --- a/src/_components/Modal/DateDetails/DateDetails.tsx
44
+ +++ b/src/_components/Modal/DateDetails/DateDetails.tsx
45
+ @@ -232,7 +232,7 @@ export default function DateDetails({
46
+ />
47
+ </FormRow>
48
+ <div>
49
+ - <NoticeFileLink fileKey={selectedCourtDate.NoticeFile} />
50
+ + <NoticeFileLink fileKeys={selectedCourtDate.NoticeFile} />
51
+ </div>
52
+ <FormRow gap={3}>
53
+ <StatusTimeline
54
+ diff --git a/src/_components/Modal/DateDetails/NoticeFileLink.tsx b/src/_components/Modal/DateDetails/NoticeFileLink.tsx
55
+ index 673fcc6..f8a36e4 100644
56
+ --- a/src/_components/Modal/DateDetails/NoticeFileLink.tsx
57
+ +++ b/src/_components/Modal/DateDetails/NoticeFileLink.tsx
58
+ @@ -4,41 +4,57 @@ import DescriptionIcon from '@mui/icons-material/Description';
59
+ import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined';
60
+ import { S3_NOTICES_URL } from '@/constants';
61
+
62
+ -export default function NoticeFileLink({ fileKey }: { fileKey: string | null }) {
63
+ - const hasFile = Boolean(fileKey);
64
+ +export default function NoticeFileLink({ fileKeys }: { fileKeys: string[] | null }) {
65
+ + const files = fileKeys && fileKeys.length > 0 ? fileKeys : null;
66
+
67
+ - function handleClick() {
68
+ - if (!fileKey) return;
69
+ - const url = fileKey.startsWith('http') ? fileKey : `${S3_NOTICES_URL}/${fileKey}`;
70
+ - window.open(url, '_blank', 'noopener,noreferrer');
71
+ + if (!files) {
72
+ + return (
73
+ + <Box
74
+ + mt={1}
75
+ + mb={1}
76
+ + display='flex'
77
+ + alignItems='center'
78
+ + justifyContent='center'
79
+ + gap={1}
80
+ + borderRadius={2}
81
+ + p={1.5}
82
+ + sx={{ border: '2px solid', borderColor: 'divider', opacity: 0.5 }}
83
+ + >
84
+ + <InsertDriveFileOutlinedIcon sx={{ color: 'text.primary' }} />
85
+ + <Typography variant='subtitle1'>No Notice Files</Typography>
86
+ + </Box>
87
+ + );
88
+ }
89
+
90
+ return (
91
+ - <Box
92
+ - mt={1}
93
+ - mb={1}
94
+ - display='flex'
95
+ - alignItems='center'
96
+ - justifyContent='center'
97
+ - gap={1}
98
+ - borderRadius={2}
99
+ - p={1.5}
100
+ - sx={{
101
+ - border: '2px solid',
102
+ - borderColor: 'divider',
103
+ - cursor: hasFile ? 'pointer' : 'default',
104
+ - opacity: hasFile ? 1 : 0.5,
105
+ - transition: 'opacity 0.2s',
106
+ - '&:hover': hasFile ? { opacity: 0.8 } : undefined,
107
+ - }}
108
+ - onClick={handleClick}
109
+ - >
110
+ - {hasFile ? (
111
+ - <DescriptionIcon sx={{ color: '#66bb6a' }} />
112
+ - ) : (
113
+ - <InsertDriveFileOutlinedIcon sx={{ color: 'text.primary' }} />
114
+ - )}
115
+ - <Typography variant='subtitle1'>{hasFile ? 'View Notice File' : 'No Notice File'}</Typography>
116
+ + <Box mt={1} mb={1} display='flex' flexDirection='column' gap={0.75}>
117
+ + {files.map((key, i) => {
118
+ + const url = key.startsWith('http') ? key : `${S3_NOTICES_URL}/${key}`;
119
+ + const label = key.split('/').pop() || `Notice File ${i + 1}`;
120
+ + return (
121
+ + <Box
122
+ + key={i}
123
+ + display='flex'
124
+ + alignItems='center'
125
+ + gap={1}
126
+ + borderRadius={2}
127
+ + p={1.5}
128
+ + sx={{
129
+ + border: '2px solid',
130
+ + borderColor: 'divider',
131
+ + cursor: 'pointer',
132
+ + transition: 'opacity 0.2s',
133
+ + '&:hover': { opacity: 0.8 },
134
+ + }}
135
+ + onClick={() => window.open(url, '_blank', 'noopener,noreferrer')}
136
+ + >
137
+ + <DescriptionIcon sx={{ color: '#66bb6a', flexShrink: 0 }} />
138
+ + <Typography variant='subtitle1' noWrap title={key}>
139
+ + {label}
140
+ + </Typography>
141
+ + </Box>
142
+ + );
143
+ + })}
144
+ </Box>
145
+ );
146
+ }
147
+ diff --git a/src/_components/Modal/DateEdit/CreateEditCase.tsx b/src/_components/Modal/DateEdit/CreateEditCase.tsx
148
+ index 52f857f..ab2ebab 100644
149
+ --- a/src/_components/Modal/DateEdit/CreateEditCase.tsx
150
+ +++ b/src/_components/Modal/DateEdit/CreateEditCase.tsx
151
+ @@ -8,7 +8,10 @@ import MenuItem from '@mui/material/MenuItem';
152
+ import Select from '@mui/material/Select';
153
+ import IconButton from '@mui/material/IconButton';
154
+ import Button from '@mui/material/Button';
155
+ +import Typography from '@mui/material/Typography';
156
+ import DeleteIcon from '@mui/icons-material/Delete';
157
+ +import OpenInNewIcon from '@mui/icons-material/OpenInNew';
158
+ +import { S3_NOTICES_URL } from '@/constants';
159
+ import { DatePicker } from '@mui/x-date-pickers';
160
+ import { DateType, HearingType, Lifecycle, type Case, type CourtDate } from '@/types';
161
+ import { ModalMode } from '../types';
162
+ @@ -33,6 +36,8 @@ const CreateEditCase = memo(function CreateEditCase({
163
+ setEditedCases,
164
+ modalMode,
165
+ activeUser,
166
+ + pendingNoticeFiles,
167
+ + setPendingNoticeFiles,
168
+ }: {
169
+ edited: CourtDate | undefined;
170
+ setEdited: (data: CourtDate) => void;
171
+ @@ -40,6 +45,8 @@ const CreateEditCase = memo(function CreateEditCase({
172
+ setEditedCases: (cases: Case[]) => void;
173
+ modalMode: ModalMode;
174
+ activeUser: number;
175
+ + pendingNoticeFiles: File[];
176
+ + setPendingNoticeFiles: (files: File[]) => void;
177
+ }) {
178
+ const { allUsers, isAdmin } = useReferenceData();
179
+ const { filterCtx } = useFilter();
180
+ @@ -158,6 +165,66 @@ const CreateEditCase = memo(function CreateEditCase({
181
+ onChange={(notes) => setEditedInterrupt({ ...edited, Notes: notes })}
182
+ />
183
+ </FormRow>
184
+ + <FormRow gap={7} header='Notice Files' subheader='Attach court notice PDFs to this date.'>
185
+ + <Stack direction='column' spacing={1} width='100%'>
186
+ + {(edited.NoticeFile || []).map((key, i) => (
187
+ + <Stack key={i} direction='row' alignItems='center' spacing={1}>
188
+ + <Typography variant='body2' sx={{ flex: 1 }} noWrap title={key}>
189
+ + {key.split('/').pop() ?? key}
190
+ + </Typography>
191
+ + <IconButton
192
+ + size='small'
193
+ + component='a'
194
+ + href={key.startsWith('http') ? key : `${S3_NOTICES_URL}/${key}`}
195
+ + target='_blank'
196
+ + rel='noopener noreferrer'
197
+ + >
198
+ + <OpenInNewIcon fontSize='small' />
199
+ + </IconButton>
200
+ + <IconButton
201
+ + size='small'
202
+ + onClick={() => {
203
+ + const updated = (edited.NoticeFile || []).filter((_, j) => j !== i);
204
+ + setEditedInterrupt({ ...edited, NoticeFile: updated.length ? updated : null });
205
+ + }}
206
+ + >
207
+ + <DeleteIcon fontSize='small' />
208
+ + </IconButton>
209
+ + </Stack>
210
+ + ))}
211
+ + {pendingNoticeFiles.map((file, i) => (
212
+ + <Stack key={`pending-${i}`} direction='row' alignItems='center' spacing={1}>
213
+ + <Typography variant='body2' sx={{ flex: 1, color: 'text.secondary' }} noWrap>
214
+ + {file.name} <em>(pending)</em>
215
+ + </Typography>
216
+ + <IconButton
217
+ + size='small'
218
+ + onClick={() => setPendingNoticeFiles(pendingNoticeFiles.filter((_, j) => j !== i))}
219
+ + >
220
+ + <DeleteIcon fontSize='small' />
221
+ + </IconButton>
222
+ + </Stack>
223
+ + ))}
224
+ + <Button
225
+ + variant='outlined'
226
+ + size='small'
227
+ + component='label'
228
+ + sx={{ alignSelf: 'flex-start' }}
229
+ + >
230
+ + + Attach Notice File
231
+ + <input
232
+ + type='file'
233
+ + accept='application/pdf'
234
+ + hidden
235
+ + onChange={(e) => {
236
+ + const file = e.target.files?.[0];
237
+ + if (file) setPendingNoticeFiles([...pendingNoticeFiles, file]);
238
+ + e.target.value = '';
239
+ + }}
240
+ + />
241
+ + </Button>
242
+ + </Stack>
243
+ + </FormRow>
244
+ </Box>
245
+
246
+ <Divider />
247
+ diff --git a/src/_components/Modal/Modal.tsx b/src/_components/Modal/Modal.tsx
248
+ index 15cfb54..c8478bd 100644
249
+ --- a/src/_components/Modal/Modal.tsx
250
+ +++ b/src/_components/Modal/Modal.tsx
251
+ @@ -106,6 +106,11 @@ export default function CCModal({
252
+ }) {
253
+ const { getTownshipName, isAdmin, activeUser } = useReferenceData();
254
+ const [savedSnack, setSavedSnack] = useState(false);
255
+ + const [pendingNoticeFiles, setPendingNoticeFiles] = useState<File[]>([]);
256
+ +
257
+ + useEffect(() => {
258
+ + if (!modalIsOpen) setPendingNoticeFiles([]);
259
+ + }, [modalIsOpen]);
260
+
261
+ useEffect(() => {
262
+ if (!modalIsOpen) return;
263
+ @@ -128,6 +133,7 @@ export default function CCModal({
264
+ editedCases,
265
+ setEditedData,
266
+ setIsOpen,
267
+ + pendingNoticeFiles,
268
+ });
269
+
270
+ async function handleSave() {
271
+ @@ -214,6 +220,8 @@ export default function CCModal({
272
+ setEditedCases={setEditedCases}
273
+ modalMode={modalMode}
274
+ activeUser={activeUser}
275
+ + pendingNoticeFiles={pendingNoticeFiles}
276
+ + setPendingNoticeFiles={setPendingNoticeFiles}
277
+ />
278
+ </ModalPanel>
279
+ )}
280
+ diff --git a/src/helpers/api/courtDates.ts b/src/helpers/api/courtDates.ts
281
+ index fe5a85a..617e67c 100644
282
+ --- a/src/helpers/api/courtDates.ts
283
+ +++ b/src/helpers/api/courtDates.ts
284
+ @@ -2,6 +2,58 @@ import { DateType, HearingType, Lifecycle, SourceType, type CourtDate, type Date
285
+ import { formatDateForAPI, formatDateTimeForAPI } from '../formatter';
286
+ import { API_BASE_URL } from '../../constants';
287
+
288
+ +// raw is a loosely-typed API response object being normalized in place
289
+ +function normalizeDateFromApi(raw: CourtDate & { NegDateID?: number | string; court_date_id?: number | string; Date?: string; Time?: string; skip_motion?: boolean | null; motion_date?: string | null }, type: DateType): CourtDate {
290
+ + if (raw.court_date_id !== undefined && raw.CourtDateID == null) raw.CourtDateID = Number(raw.court_date_id);
291
+ + if (raw.NegDateID !== undefined && raw.CourtDateID == null) raw.CourtDateID = Number(raw.NegDateID);
292
+ + if (raw.CourtDateID != null) raw.CourtDateID = Number(raw.CourtDateID);
293
+ + if (raw.Date !== undefined && raw.CourtDate == null) raw.CourtDate = new Date(raw.Date);
294
+ + if (raw.Time !== undefined && raw.HearingTime == null) raw.HearingTime = raw.Time;
295
+ + if (!raw.Lifecycle) raw.Lifecycle = Lifecycle.SCHEDULED;
296
+ + if (!raw.Type) raw.Type = HearingType.UNKNOWN;
297
+ + if (!raw.DateType) raw.DateType = type;
298
+ + if (raw.skip_motion !== undefined && raw.SkipMotion == null) raw.SkipMotion = raw.skip_motion;
299
+ + if (raw.motion_date !== undefined && raw.MotionDate == null) raw.MotionDate = raw.motion_date ? new Date(raw.motion_date) : null;
300
+ +
301
+ + const rawMD = raw.MultipleDates as unknown;
302
+ + const mdArray: string[] | null = Array.isArray(rawMD)
303
+ + ? rawMD as string[]
304
+ + : typeof rawMD === 'string'
305
+ + ? (() => {
306
+ + try {
307
+ + let parsed: unknown = rawMD;
308
+ + while (typeof parsed === 'string') parsed = JSON.parse(parsed);
309
+ + return Array.isArray(parsed) ? (parsed as string[]) : null;
310
+ + } catch {
311
+ + return null;
312
+ + }
313
+ + })()
314
+ + : null;
315
+ + if (mdArray == null && rawMD != null) {
316
+ + console.warn(`⚠️ CourtDate #${raw.CourtDateID} — unrecognized MultipleDates format:`, rawMD);
317
+ + }
318
+ + raw.MultipleDates = Array.isArray(mdArray)
319
+ + ? mdArray.map((d: string) => {
320
+ + const [year, month, day] = d.split('T')[0].split('-').map(Number);
321
+ + return new Date(year, month - 1, day);
322
+ + })
323
+ + : null;
324
+ +
325
+ + const rawNF = raw.NoticeFile as unknown;
326
+ + raw.NoticeFile = Array.isArray(rawNF) ? rawNF as string[] : typeof rawNF === 'string' && rawNF ? [rawNF] : null;
327
+ +
328
+ + if (typeof raw.Notes === 'string') {
329
+ + try {
330
+ + const parsed: unknown = JSON.parse(raw.Notes);
331
+ + raw.Notes = Array.isArray(parsed) ? parsed as import('../../types').Notes[] : null;
332
+ + } catch {
333
+ + raw.Notes = null;
334
+ + }
335
+ + }
336
+ +
337
+ + return raw;
338
+ +}
339
+ +
340
+ export async function getAllDates(apiKey: string, type?: DateType): Promise<Array<CourtDate>> {
341
+ if (!apiKey) return [];
342
+
343
+ @@ -15,55 +67,7 @@ export async function getAllDates(apiKey: string, type?: DateType): Promise<Arra
344
+ if (!type) type = DateType.SCAR; // default to SCAR if no type provided
345
+
346
+ for (const date of data.dates) {
347
+ - if (date.NegDateID !== undefined && date.CourtDateID == null) {
348
+ - date.CourtDateID = date.NegDateID;
349
+ - }
350
+ - if (date.Date !== undefined && date.CourtDate == null) {
351
+ - date.CourtDate = new Date(date.Date);
352
+ - }
353
+ - if (date.Time !== undefined && date.HearingTime == null) {
354
+ - date.HearingTime = date.Time;
355
+ - }
356
+ - if (!date.Lifecycle) {
357
+ - date.Lifecycle = Lifecycle.SCHEDULED;
358
+ - }
359
+ - if (!date.Type) {
360
+ - date.Type = HearingType.UNKNOWN;
361
+ - }
362
+ - if (!date.DateType) {
363
+ - date.DateType = type;
364
+ - }
365
+ - if (date.skip_motion !== undefined && date.SkipMotion == null) {
366
+ - date.SkipMotion = date.skip_motion;
367
+ - }
368
+ - if (date.motion_date !== undefined && date.MotionDate == null) {
369
+ - date.MotionDate = date.motion_date;
370
+ - }
371
+ - const rawMD = date.MultipleDates;
372
+ - const mdArray: string[] | null = Array.isArray(rawMD)
373
+ - ? rawMD
374
+ - : typeof rawMD === 'string'
375
+ - ? (() => {
376
+ - try {
377
+ - let parsed: unknown = rawMD;
378
+ - while (typeof parsed === 'string') {
379
+ - parsed = JSON.parse(parsed);
380
+ - }
381
+ - return Array.isArray(parsed) ? (parsed as string[]) : null;
382
+ - } catch {
383
+ - return null;
384
+ - }
385
+ - })()
386
+ - : null;
387
+ - if (mdArray == null && rawMD != null) {
388
+ - console.warn(`⚠️ CourtDate #${date.CourtDateID} — unrecognized MultipleDates format:`, rawMD);
389
+ - }
390
+ - date.MultipleDates = Array.isArray(mdArray)
391
+ - ? mdArray.map((d: string) => {
392
+ - const [year, month, day] = d.split('T')[0].split('-').map(Number);
393
+ - return new Date(year, month - 1, day);
394
+ - })
395
+ - : null;
396
+ + normalizeDateFromApi(date, type);
397
+ }
398
+
399
+ const dates: CourtDate[] = data.dates || [];
400
+ @@ -91,34 +95,55 @@ export async function updateCourtDate(
401
+ user?: number,
402
+ type?: DateType,
403
+ skipMotion?: boolean,
404
+ + noticeFiles?: File[],
405
+ ): Promise<boolean> {
406
+ if (!apiKey) return false;
407
+ +
408
+ + const fields: Record<string, unknown> = {
409
+ + court_date: updatedData.CourtDate
410
+ + ? formatDateTimeForAPI(new Date(updatedData.CourtDate), updatedData.HearingTime || undefined)
411
+ + : undefined,
412
+ + hearing_link: updatedData.HearingLink !== '' ? updatedData.HearingLink : undefined,
413
+ + hearing_officer: updatedData.HearingOfficer !== undefined ? updatedData.HearingOfficer : undefined,
414
+ + hearing_time: updatedData.HearingTime !== '' ? updatedData.HearingTime : undefined,
415
+ + hearing_type: updatedData.Type !== HearingType.UNKNOWN ? updatedData.Type : undefined,
416
+ + muni: updatedData.MuniCode,
417
+ + first_chair: updatedData.FirstChair !== undefined ? updatedData.FirstChair : undefined,
418
+ + second_chair: updatedData.SecondChair !== undefined ? updatedData.SecondChair : undefined,
419
+ + lifecycle: updatedData.Lifecycle !== Lifecycle.SCHEDULED ? updatedData.Lifecycle : undefined,
420
+ + court_cases: courtCases || undefined,
421
+ + notes: updatedData.Notes !== null ? updatedData.Notes : undefined,
422
+ + edit_user: user !== undefined ? user : undefined,
423
+ + multiple_dates: updatedData.MultipleDates?.map((d) => formatDateForAPI(new Date(d))) ?? undefined,
424
+ + type: type !== DateType.SCAR ? type : undefined,
425
+ + skip_motion: skipMotion !== undefined ? skipMotion : undefined,
426
+ + notice_files: updatedData.NoticeFile ?? undefined,
427
+ + };
428
+ +
429
+ + const hasNewFiles = noticeFiles && noticeFiles.length > 0;
430
+ + let body: BodyInit;
431
+ + const headers: Record<string, string> = { 'x-api-key': apiKey };
432
+ +
433
+ + if (hasNewFiles) {
434
+ + const form = new FormData();
435
+ + for (const [key, val] of Object.entries(fields)) {
436
+ + if (val === undefined) continue;
437
+ + form.append(key, typeof val === 'string' ? val : JSON.stringify(val));
438
+ + }
439
+ + for (const file of noticeFiles!) {
440
+ + form.append('notice_file', file);
441
+ + }
442
+ + body = form;
443
+ + } else {
444
+ + headers['Content-Type'] = 'application/json';
445
+ + body = JSON.stringify(fields);
446
+ + }
447
+ +
448
+ const res = await fetch(`${API_BASE_URL}/court-dates/${courtDateId}/update`, {
449
+ method: 'PUT',
450
+ mode: 'cors',
451
+ - headers: {
452
+ - 'Content-Type': 'application/json',
453
+ - 'x-api-key': apiKey,
454
+ - },
455
+ - body: JSON.stringify({
456
+ - court_date: updatedData.CourtDate
457
+ - ? formatDateTimeForAPI(new Date(updatedData.CourtDate), updatedData.HearingTime || undefined)
458
+ - : undefined,
459
+ - hearing_link: updatedData.HearingLink !== '' ? updatedData.HearingLink : undefined,
460
+ - hearing_officer: updatedData.HearingOfficer !== undefined ? updatedData.HearingOfficer : undefined,
461
+ - hearing_time: updatedData.HearingTime !== '' ? updatedData.HearingTime : undefined,
462
+ - hearing_type: updatedData.Type !== HearingType.UNKNOWN ? updatedData.Type : undefined,
463
+ - muni: updatedData.MuniCode,
464
+ - first_chair: updatedData.FirstChair !== undefined ? updatedData.FirstChair : undefined,
465
+ - second_chair: updatedData.SecondChair !== undefined ? updatedData.SecondChair : undefined,
466
+ - lifecycle: updatedData.Lifecycle !== Lifecycle.SCHEDULED ? updatedData.Lifecycle : undefined,
467
+ - court_cases: courtCases || undefined,
468
+ - notes: updatedData.Notes !== null ? updatedData.Notes : undefined,
469
+ - edit_user: user !== undefined ? user : undefined,
470
+ - multiple_dates: updatedData.MultipleDates?.map((d) => formatDateForAPI(new Date(d))) ?? undefined,
471
+ - type: type !== DateType.SCAR ? type : undefined,
472
+ - skip_motion: skipMotion !== undefined ? skipMotion : undefined,
473
+ - }),
474
+ + headers,
475
+ + body,
476
+ });
477
+ if (res.ok) {
478
+ return true;
479
+ @@ -128,6 +153,18 @@ export async function updateCourtDate(
480
+ }
481
+ }
482
+
483
+ +export async function getCourtDate(courtDateId: number, apiKey: string, type?: DateType): Promise<CourtDate | null> {
484
+ + if (!apiKey) return null;
485
+ + const res = await fetch(`${API_BASE_URL}/court-dates/${courtDateId}${type ? `?type=${type}` : ''}`, {
486
+ + mode: 'cors',
487
+ + headers: { 'x-api-key': apiKey },
488
+ + });
489
+ + if (!res.ok) return null;
490
+ + const data = await res.json();
491
+ + const raw = data.court_date ?? data.date ?? data;
492
+ + return normalizeDateFromApi(raw, type ?? DateType.SCAR);
493
+ +}
494
+ +
495
+ export async function deleteCourtDate(courtDateId: number, apiKey: string, type?: DateType): Promise<boolean> {
496
+ if (!apiKey) return false;
497
+ const res = await fetch(`${API_BASE_URL}/court-dates/${courtDateId}${type ? `?type=${type}` : ''}`, {
498
+ @@ -154,24 +191,46 @@ export async function createCourtDate(
499
+ hearingTime?: string,
500
+ multipleDates?: Date[],
501
+ type?: DateType,
502
+ + noticeFiles?: File[],
503
+ ): Promise<number | null> {
504
+ if (!apiKey) return null;
505
+ if (!type) type = DateType.SCAR; // default to SCAR if no type provided
506
+ - const res = await fetch(`${API_BASE_URL}/court-dates/create`, {
507
+ - method: 'POST',
508
+ - mode: 'cors',
509
+ - headers: {
510
+ - 'Content-Type': 'application/json',
511
+ - 'x-api-key': apiKey,
512
+ - },
513
+ - body: JSON.stringify({
514
+ +
515
+ + const hasNewFiles = noticeFiles && noticeFiles.length > 0;
516
+ + let body: BodyInit;
517
+ + const headers: Record<string, string> = { 'x-api-key': apiKey };
518
+ +
519
+ + if (hasNewFiles) {
520
+ + const form = new FormData();
521
+ + form.append('court_date', formatDateTimeForAPI(new Date(courtDate), hearingTime || undefined));
522
+ + form.append('muni', muniCode);
523
+ + form.append('source', SourceType.MANUAL);
524
+ + form.append('court_cases', JSON.stringify(courtCases || []));
525
+ + if (multipleDates?.length) {
526
+ + form.append('multiple_dates', JSON.stringify(multipleDates.map((d) => formatDateForAPI(new Date(d)))));
527
+ + }
528
+ + form.append('type', type);
529
+ + for (const file of noticeFiles!) {
530
+ + form.append('notice_file', file);
531
+ + }
532
+ + body = form;
533
+ + } else {
534
+ + headers['Content-Type'] = 'application/json';
535
+ + body = JSON.stringify({
536
+ court_date: formatDateTimeForAPI(new Date(courtDate), hearingTime || undefined),
537
+ muni: muniCode,
538
+ source: SourceType.MANUAL,
539
+ court_cases: courtCases || [],
540
+ multiple_dates: multipleDates?.map((d) => formatDateForAPI(new Date(d))) ?? undefined,
541
+ type: type,
542
+ - }),
543
+ + });
544
+ + }
545
+ +
546
+ + const res = await fetch(`${API_BASE_URL}/court-dates/create`, {
547
+ + method: 'POST',
548
+ + mode: 'cors',
549
+ + headers,
550
+ + body,
551
+ });
552
+ if (res.ok) {
553
+ const data = await res.json();
554
+ diff --git a/src/hooks/UseModalActions.ts b/src/hooks/UseModalActions.ts
555
+ index 25bead5..133918f 100644
556
+ --- a/src/hooks/UseModalActions.ts
557
+ +++ b/src/hooks/UseModalActions.ts
558
+ @@ -1,7 +1,7 @@
559
+ -import { type Case, type CourtDate } from '@/types';
560
+ +import { DateType, type Case, type CourtDate } from '@/types';
561
+ import { caseKey } from '@/helpers/courtDates';
562
+ import { ModalMode } from '@/_components/Modal/types';
563
+ -import { createCourtDate, deleteCourtDate, updateCourtDate } from '@/helpers/api/courtDates';
564
+ +import { createCourtDate, deleteCourtDate, getCourtDate, updateCourtDate } from '@/helpers/api/courtDates';
565
+ import { adjournCases } from '@/helpers/api/cases';
566
+ import { useReferenceData } from '@/context/ReferenceDataContext';
567
+ import { useCalendar } from '@/context/CalendarContext';
568
+ @@ -35,6 +35,7 @@ export function useModalActions({
569
+ editedCases,
570
+ setEditedData,
571
+ setIsOpen,
572
+ + pendingNoticeFiles,
573
+ }: {
574
+ modalMode: ModalMode;
575
+ selectedCourtDate: CourtDate | null;
576
+ @@ -43,9 +44,17 @@ export function useModalActions({
577
+ editedCases: Case[];
578
+ setEditedData: (data: CourtDate) => void;
579
+ setIsOpen: (isOpen: boolean) => void;
580
+ + pendingNoticeFiles?: File[];
581
+ }) {
582
+ const { apiKey, activeUser } = useReferenceData();
583
+ const { updateCourtDateInMemory, updateCases, deleteCases } = useCalendar();
584
+ + const files = pendingNoticeFiles ?? [];
585
+ +
586
+ + function refreshNoticeFiles(courtDateId: number, type?: DateType) {
587
+ + getCourtDate(courtDateId, apiKey, type).then((fresh) => {
588
+ + if (fresh?.CourtDateID) updateCourtDateInMemory(fresh);
589
+ + });
590
+ + }
591
+ function validateEditedData(): { valid: boolean; message?: string } {
592
+ if (!editedData) return { valid: false, message: 'No data to validate' };
593
+ if (!editedData.CourtDate) return { valid: false, message: 'CourtDate is required' };
594
+ @@ -102,6 +111,7 @@ export function useModalActions({
595
+ editedData.HearingTime || undefined,
596
+ undefined,
597
+ editedData.DateType ?? undefined,
598
+ + files.length > 0 ? files : undefined,
599
+ );
600
+ if (id) {
601
+ const updatedData = { ...editedData, CourtDateID: id };
602
+ @@ -109,6 +119,7 @@ export function useModalActions({
603
+ await updateCourtDate(id, updatedData, apiKey, undefined, undefined, updatedData.DateType ?? undefined);
604
+ updateCourtDateInMemory(updatedData);
605
+ updateCases({ [caseKey(updatedData)]: editedCases });
606
+ + if (files.length > 0) refreshNoticeFiles(id, editedData.DateType ?? undefined);
607
+ }
608
+ setIsOpen(false);
609
+ return true;
610
+ @@ -131,6 +142,8 @@ export function useModalActions({
611
+ editedCases.map((c) => c.SCARIndexNumber),
612
+ activeUser,
613
+ editedData.DateType ?? undefined,
614
+ + undefined,
615
+ + files.length > 0 ? files : undefined,
616
+ );
617
+ const courtDateId = editedData.CourtDateID;
618
+ if (courtDateId) {
619
+ @@ -138,6 +151,7 @@ export function useModalActions({
620
+ }
621
+ setIsOpen(false);
622
+ updateCourtDateInMemory(editedData);
623
+ + if (files.length > 0) refreshNoticeFiles(editedData.CourtDateID, editedData.DateType ?? undefined);
624
+ return true;
625
+ }
626
+
627
+ diff --git a/src/types.ts b/src/types.ts
628
+ index 23138cb..964d6bf 100644
629
+ --- a/src/types.ts
630
+ +++ b/src/types.ts
631
+ @@ -20,7 +20,7 @@ export interface CourtDate {
632
+ LastUpdateDate: Date | null;
633
+
634
+ Notes: Notes[] | null;
635
+ - NoticeFile: string | null;
636
+ + NoticeFile: string[] | null;
637
+ MultipleDates: Date[] | null;
638
+
639
+ // in person specific
640
+ warning: in the working copy of 'src/_components/Modal/DateDetails/NoticeFileLink.tsx', LF will be replaced by CRLF the next time Git touches it
641
+ diff --git a/src/__tests__/helpers/api/courtDates.test.ts b/src/__tests__/helpers/api/courtDates.test.ts
642
+ index 0f61ca1..4ecad6d 100644
643
+ --- a/src/__tests__/helpers/api/courtDates.test.ts
644
+ +++ b/src/__tests__/helpers/api/courtDates.test.ts
645
+ @@ -118,11 +118,11 @@ describe('createCourtDate', () => {
646
+ expect(id).toBeNull();
647
+ });
648
+
649
+ - it('defaults type to SCAR when not provided', async () => {
650
+ + it('omits type from body when type is SCAR (default)', async () => {
651
+ vi.stubGlobal('fetch', mockFetch({ court_date_id: 1 }));
652
+ await createCourtDate(new Date(2025, 5, 15), '3100', MOCK_API_KEY);
653
+ const body = JSON.parse((fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
654
+ - expect(body.type).toBe(DateType.SCAR);
655
+ + expect(body.type).toBeUndefined();
656
+ });
657
+
658
+ it('passes DateType.NEGOTIATIONS in body when specified', async () => {
659
+ diff --git a/src/_components/CCalendar.tsx b/src/_components/CCalendar.tsx
660
+ index ea27258..1ef0b35 100644
661
+ --- a/src/_components/CCalendar.tsx
662
+ +++ b/src/_components/CCalendar.tsx
663
+ @@ -146,6 +146,7 @@ function CCalendarInner({
664
+ forceRefreshDates,
665
+ updateCourtDateInMemory,
666
+ handleUpdateChair,
667
+ + loadRange,
668
+ } = useCourtDates({ apiKey, activeUser, modalIsOpen, setSelectedCourtDate });
669
+ const allDates = useMemo(
670
+ () => [...courtDates, ...negotiations, ...collections],
671
+ @@ -471,9 +472,10 @@ function CCalendarInner({
672
+ events={events}
673
+ eventClick={handleEventClickWithDebug}
674
+ dateClick={handleDateClick}
675
+ - datesSet={(dateInfo) =>
676
+ - setCurrentDate(dateInfo.view.currentStart)
677
+ - }
678
+ + datesSet={(dateInfo) => {
679
+ + setCurrentDate(dateInfo.view.currentStart);
680
+ + loadRange(dateInfo.view.activeStart, dateInfo.view.activeEnd);
681
+ + }}
682
+ eventDidMount={(info) =>
683
+ handleEventMount(info, calendarApi)
684
+ }
685
+ diff --git a/src/_components/Modal/DateDetails/NoticeFileLink.tsx b/src/_components/Modal/DateDetails/NoticeFileLink.tsx
686
+ index f8a36e4..57253f5 100644
687
+ --- a/src/_components/Modal/DateDetails/NoticeFileLink.tsx
688
+ +++ b/src/_components/Modal/DateDetails/NoticeFileLink.tsx
689
+ @@ -33,7 +33,7 @@ export default function NoticeFileLink({ fileKeys }: { fileKeys: string[] | null
690
+ const label = key.split('/').pop() || `Notice File ${i + 1}`;
691
+ return (
692
+ <Box
693
+ - key={i}
694
+ + key={key}
695
+ display='flex'
696
+ alignItems='center'
697
+ gap={1}
698
+ diff --git a/src/_components/Modal/DateEdit/CreateEditCase.tsx b/src/_components/Modal/DateEdit/CreateEditCase.tsx
699
+ index ab2ebab..b558dc0 100644
700
+ --- a/src/_components/Modal/DateEdit/CreateEditCase.tsx
701
+ +++ b/src/_components/Modal/DateEdit/CreateEditCase.tsx
702
+ @@ -168,7 +168,7 @@ const CreateEditCase = memo(function CreateEditCase({
703
+ <FormRow gap={7} header='Notice Files' subheader='Attach court notice PDFs to this date.'>
704
+ <Stack direction='column' spacing={1} width='100%'>
705
+ {(edited.NoticeFile || []).map((key, i) => (
706
+ - <Stack key={i} direction='row' alignItems='center' spacing={1}>
707
+ + <Stack key={key} direction='row' alignItems='center' spacing={1}>
708
+ <Typography variant='body2' sx={{ flex: 1 }} noWrap title={key}>
709
+ {key.split('/').pop() ?? key}
710
+ </Typography>
711
+ @@ -193,7 +193,7 @@ const CreateEditCase = memo(function CreateEditCase({
712
+ </Stack>
713
+ ))}
714
+ {pendingNoticeFiles.map((file, i) => (
715
+ - <Stack key={`pending-${i}`} direction='row' alignItems='center' spacing={1}>
716
+ + <Stack key={`${file.name}-${file.lastModified}`} direction='row' alignItems='center' spacing={1}>
717
+ <Typography variant='body2' sx={{ flex: 1, color: 'text.secondary' }} noWrap>
718
+ {file.name} <em>(pending)</em>
719
+ </Typography>
720
+ diff --git a/src/constants.ts b/src/constants.ts
721
+ index 26edfef..563883e 100644
722
+ --- a/src/constants.ts
723
+ +++ b/src/constants.ts
724
+ @@ -16,6 +16,8 @@ export const COURT_DATES_CACHE_EXPIRY = 1000 * 60 * 1; // 1 minute
725
+ export const NEGOTIATIONS_CACHE_EXPIRY = 1000 * 60 * 1; // 1 minute
726
+ export const COLLECTIONS_CACHE_EXPIRY = 1000 * 60 * 1; // 1 minute
727
+ export const COURT_DATES_POLL_INTERVAL = 1000 * 60 * 5; // 5 minutes
728
+ +export const WARM_CACHE_TTL_MS = 1000 * 60 * 10; // 10 minutes — for on-demand loaded months (change infrequently)
729
+ +export const HOT_WINDOW_DAYS = 90; // days back from today included in initial fetch
730
+
731
+ export const DEFAULT_FILTER_CTX: CalendarFilterCtx = {
732
+ showInPerson: true,
733
+ diff --git a/src/helpers/CalEvent.ts b/src/helpers/CalEvent.ts
734
+ index 1d03a4e..aa36405 100644
735
+ --- a/src/helpers/CalEvent.ts
736
+ +++ b/src/helpers/CalEvent.ts
737
+ @@ -120,7 +120,9 @@ export function buildCalEvents(
738
+ notes: cd.Notes ?? null,
739
+ originalDate: new Date(cd.CourtDate),
740
+ uploadDeadline: cd.UploadDeadline ? parseLocalDate(String(cd.UploadDeadline)) : null,
741
+ - motionDate: cd.MotionDate ? parseLocalDate(String(cd.MotionDate)) : null,
742
+ + motionDate: cd.MotionDate
743
+ + ? (cd.MotionDate instanceof Date ? cd.MotionDate : parseLocalDate(String(cd.MotionDate)))
744
+ + : null,
745
+ source: cd,
746
+ };
747
+
748
+ diff --git a/src/helpers/api/cases.ts b/src/helpers/api/cases.ts
749
+ index c2f39fb..348a5ae 100644
750
+ --- a/src/helpers/api/cases.ts
751
+ +++ b/src/helpers/api/cases.ts
752
+ @@ -50,12 +50,14 @@ export async function fetchCasesByCourtDate(id: string, apiKey: string, type?: D
753
+ return cases;
754
+ }
755
+
756
+ -export async function* fetchAllCasesPaginated(apiKey: string, pageSize = 20) {
757
+ +export async function* fetchAllCasesPaginated(apiKey: string, pageSize = 20, dateFrom?: string) {
758
+ if (!apiKey) return;
759
+ let page = 1;
760
+ let totalPages = 1;
761
+ do {
762
+ - const res = await fetch(`${API_BASE_URL}/court-cases/filtering?page=${page}&page_size=${pageSize}`, {
763
+ + const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
764
+ + if (dateFrom) params.set('date_from', dateFrom);
765
+ + const res = await fetch(`${API_BASE_URL}/court-cases/filtering?${params}`, {
766
+ mode: 'cors',
767
+ headers: { 'x-api-key': apiKey },
768
+ method: 'GET',
769
+ diff --git a/src/helpers/api/courtDates.ts b/src/helpers/api/courtDates.ts
770
+ index 617e67c..7414ae3 100644
771
+ --- a/src/helpers/api/courtDates.ts
772
+ +++ b/src/helpers/api/courtDates.ts
773
+ @@ -54,16 +54,23 @@ function normalizeDateFromApi(raw: CourtDate & { NegDateID?: number | string; co
774
+ return raw;
775
+ }
776
+
777
+ -export async function getAllDates(apiKey: string, type?: DateType): Promise<Array<CourtDate>> {
778
+ +export async function getAllDates(apiKey: string, type?: DateType, dateFrom?: string, dateTo?: string): Promise<Array<CourtDate>> {
779
+ if (!apiKey) return [];
780
+
781
+ - const res = await fetch(`${API_BASE_URL}/court-dates/all${type ? `?type=${type}` : ''}`, {
782
+ + const params = new URLSearchParams();
783
+ + if (type) params.set('type', type);
784
+ + if (dateFrom) params.set('date_from', dateFrom);
785
+ + if (dateTo) params.set('date_to', dateTo);
786
+ + const qs = params.toString();
787
+ +
788
+ + const res = await fetch(`${API_BASE_URL}/court-dates/all${qs ? `?${qs}` : ''}`, {
789
+ mode: 'cors',
790
+ headers: {
791
+ 'x-api-key': apiKey,
792
+ },
793
+ });
794
+ const data = await res.json();
795
+ + if (!res.ok) throw new Error(`HTTP ${res.status}: ${data?.error ?? data?.message ?? 'unknown error'}`);
796
+ if (!type) type = DateType.SCAR; // default to SCAR if no type provided
797
+
798
+ for (const date of data.dates) {
799
+ @@ -161,7 +168,8 @@ export async function getCourtDate(courtDateId: number, apiKey: string, type?: D
800
+ });
801
+ if (!res.ok) return null;
802
+ const data = await res.json();
803
+ - const raw = data.court_date ?? data.date ?? data;
804
+ + const raw = data.court_date ?? data.date ?? null;
805
+ + if (!raw) return null;
806
+ return normalizeDateFromApi(raw, type ?? DateType.SCAR);
807
+ }
808
+
809
+ @@ -209,7 +217,7 @@ export async function createCourtDate(
810
+ if (multipleDates?.length) {
811
+ form.append('multiple_dates', JSON.stringify(multipleDates.map((d) => formatDateForAPI(new Date(d)))));
812
+ }
813
+ - form.append('type', type);
814
+ + if (type !== DateType.SCAR) form.append('type', type);
815
+ for (const file of noticeFiles!) {
816
+ form.append('notice_file', file);
817
+ }
818
+ @@ -222,7 +230,7 @@ export async function createCourtDate(
819
+ source: SourceType.MANUAL,
820
+ court_cases: courtCases || [],
821
+ multiple_dates: multipleDates?.map((d) => formatDateForAPI(new Date(d))) ?? undefined,
822
+ - type: type,
823
+ + type: type !== DateType.SCAR ? type : undefined,
824
+ });
825
+ }
826
+
827
+ diff --git a/src/helpers/cache.ts b/src/helpers/cache.ts
828
+ index 95cde57..6f4c582 100644
829
+ --- a/src/helpers/cache.ts
830
+ +++ b/src/helpers/cache.ts
831
+ @@ -5,9 +5,9 @@ import { CASES_CACHE_EXPIRY, COURT_DATES_CACHE_EXPIRY, NEGOTIATIONS_CACHE_EXPIRY
832
+ // Define Dexie database
833
+ class CasesDB extends Dexie {
834
+ cases!: Table<{ courtDateId: string; data: Case[]; timestamp: number }, string>;
835
+ - courtDates!: Table<{ id: string; data: CourtDate[]; timestamp: number }, string>;
836
+ - negotiations!: Table<{ id: string; data: CourtDate[]; timestamp: number }, string>;
837
+ - collections!: Table<{ id: string; data: CourtDate[]; timestamp: number }, string>;
838
+ + courtDates!: Table<{ id: string; data: CourtDate[]; timestamp: number; ttl?: number }, string>;
839
+ + negotiations!: Table<{ id: string; data: CourtDate[]; timestamp: number; ttl?: number }, string>;
840
+ + collections!: Table<{ id: string; data: CourtDate[]; timestamp: number; ttl?: number }, string>;
841
+ constructor() {
842
+ super('CourtCasesDB');
843
+ this.version(1).stores({
844
+ @@ -40,6 +40,17 @@ class CasesDB extends Dexie {
845
+ negotiations: 'id',
846
+ collections: 'id',
847
+ });
848
+ + // v7: evict court-date caches to coerce NoticeFile string → string[] after type change
849
+ + this.version(7).stores({
850
+ + cases: 'courtDateId',
851
+ + courtDates: 'id',
852
+ + negotiations: 'id',
853
+ + collections: 'id',
854
+ + }).upgrade(tx => Promise.all([
855
+ + tx.table('courtDates').clear(),
856
+ + tx.table('negotiations').clear(),
857
+ + tx.table('collections').clear(),
858
+ + ]));
859
+ }
860
+ }
861
+
862
+ @@ -109,15 +120,15 @@ export async function clearAllCache() {
863
+ export async function getCourtDatesCache(): Promise<CourtDate[] | null> {
864
+ const entry = await db.courtDates.get('all');
865
+ const now = Date.now();
866
+ - if (entry && Array.isArray(entry.data) && now - entry.timestamp < COURT_DATES_CACHE_EXPIRY) {
867
+ + if (entry && Array.isArray(entry.data) && now - entry.timestamp < (entry.ttl ?? COURT_DATES_CACHE_EXPIRY)) {
868
+ return entry.data;
869
+ }
870
+ return null;
871
+ }
872
+
873
+ -export async function setCourtDatesCache(data: CourtDate[]) {
874
+ +export async function setCourtDatesCache(data: CourtDate[], ttl?: number) {
875
+ try {
876
+ - await db.courtDates.put({ id: 'all', data, timestamp: Date.now() });
877
+ + await db.courtDates.put({ id: 'all', data, timestamp: Date.now(), ...(ttl !== undefined && { ttl }) });
878
+ } catch (e) {
879
+ console.warn('⚠️ Cache write failed for court dates:', e);
880
+ }
881
+ @@ -127,15 +138,15 @@ export async function setCourtDatesCache(data: CourtDate[]) {
882
+ export async function getNegotiationsCache(): Promise<CourtDate[] | null> {
883
+ const entry = await db.negotiations.get('all');
884
+ const now = Date.now();
885
+ - if (entry && Array.isArray(entry.data) && now - entry.timestamp < NEGOTIATIONS_CACHE_EXPIRY) {
886
+ + if (entry && Array.isArray(entry.data) && now - entry.timestamp < (entry.ttl ?? NEGOTIATIONS_CACHE_EXPIRY)) {
887
+ return entry.data;
888
+ }
889
+ return null;
890
+ }
891
+
892
+ -export async function setNegotiationsCache(data: CourtDate[]) {
893
+ +export async function setNegotiationsCache(data: CourtDate[], ttl?: number) {
894
+ try {
895
+ - await db.negotiations.put({ id: 'all', data, timestamp: Date.now() });
896
+ + await db.negotiations.put({ id: 'all', data, timestamp: Date.now(), ...(ttl !== undefined && { ttl }) });
897
+ } catch (e) {
898
+ console.warn('⚠️ Cache write failed for negotiations:', e);
899
+ }
900
+ @@ -145,15 +156,15 @@ export async function setNegotiationsCache(data: CourtDate[]) {
901
+ export async function getCollectionsCache(): Promise<CourtDate[] | null> {
902
+ const entry = await db.collections.get('all');
903
+ const now = Date.now();
904
+ - if (entry && Array.isArray(entry.data) && now - entry.timestamp < COLLECTIONS_CACHE_EXPIRY) {
905
+ + if (entry && Array.isArray(entry.data) && now - entry.timestamp < (entry.ttl ?? COLLECTIONS_CACHE_EXPIRY)) {
906
+ return entry.data;
907
+ }
908
+ return null;
909
+ }
910
+
911
+ -export async function setCollectionsCache(data: CourtDate[]) {
912
+ +export async function setCollectionsCache(data: CourtDate[], ttl?: number) {
913
+ try {
914
+ - await db.collections.put({ id: 'all', data, timestamp: Date.now() });
915
+ + await db.collections.put({ id: 'all', data, timestamp: Date.now(), ...(ttl !== undefined && { ttl }) });
916
+ } catch (e) {
917
+ console.warn('⚠️ Cache write failed for collections:', e);
918
+ }
919
+ diff --git a/src/hooks/UseCalendarEvents.ts b/src/hooks/UseCalendarEvents.ts
920
+ index 3011249..42171b0 100644
921
+ --- a/src/hooks/UseCalendarEvents.ts
922
+ +++ b/src/hooks/UseCalendarEvents.ts
923
+ @@ -497,9 +497,11 @@ export function useCalendarEvents({
924
+ const formattedDeadline = rawDeadline
925
+ ? parseLocalDate(String(rawDeadline)).toLocaleDateString()
926
+ : "None";
927
+ - const rawMotionDate = props.MotionDate as string | null;
928
+ + const rawMotionDate = props.MotionDate as Date | string | null;
929
+ const formattedMotionDate = rawMotionDate
930
+ - ? parseLocalDate(String(rawMotionDate)).toLocaleDateString()
931
+ + ? (rawMotionDate instanceof Date
932
+ + ? rawMotionDate.toLocaleDateString()
933
+ + : parseLocalDate(String(rawMotionDate)).toLocaleDateString())
934
+ : null;
935
+ const isExtraDay = props._isExtraDay as boolean;
936
+ const isMultiDay = props._isMultiDay as boolean;
937
+ diff --git a/src/hooks/UseCaseData.ts b/src/hooks/UseCaseData.ts
938
+ index ccc2890..f155f57 100644
939
+ --- a/src/hooks/UseCaseData.ts
940
+ +++ b/src/hooks/UseCaseData.ts
941
+ @@ -3,6 +3,7 @@ import { DateType, type Case, type CourtDate } from '@/types';
942
+ import { fetchAllCasesPaginated, fetchCasesByCourtDate } from '@/helpers/api/cases';
943
+ import { getCachedCases, removeCasesCache, updateCasesCache } from '@/helpers/cache';
944
+ import { caseKey } from '@/helpers/courtDates';
945
+ +import { HOT_WINDOW_DAYS } from '@/constants';
946
+
947
+ /**
948
+ * Manages fetching, caching, and in-memory storage of cases for court dates.
949
+ @@ -39,6 +40,12 @@ export function useCaseData({
950
+ const [selectedCases, setSelectedCases] = useState<Case[]>([]);
951
+ const [isFetchingCases, setIsFetchingCases] = useState(false);
952
+
953
+ + const windowStart = useMemo(() => {
954
+ + const d = new Date();
955
+ + d.setDate(d.getDate() - HOT_WINDOW_DAYS);
956
+ + return d.toISOString().slice(0, 10);
957
+ + }, []);
958
+ +
959
+ async function getCases(
960
+ dates: CourtDate[],
961
+ skipMemory: boolean,
962
+ @@ -79,14 +86,29 @@ export function useCaseData({
963
+ const colMissingKeys = cachedMissingKeys.filter((key) => key.startsWith('col_'));
964
+
965
+ if (scarMissingKeys.length > 10) {
966
+ - for await (const fetched of fetchAllCasesPaginated(apiKey)) {
967
+ + const foundKeys = new Set<string>();
968
+ + const scarMissingKeysSet = new Set(scarMissingKeys);
969
+ + for await (const fetched of fetchAllCasesPaginated(apiKey, 20, windowStart)) {
970
+ Object.entries(fetched).forEach(([courtDateIDStr, cases]) => {
971
+ - if (scarMissingKeys.includes(courtDateIDStr)) {
972
+ + if (scarMissingKeysSet.has(courtDateIDStr)) {
973
+ fetchedCasesByCourtDate[courtDateIDStr] = cases;
974
+ + foundKeys.add(courtDateIDStr);
975
+ }
976
+ });
977
+ await addPartialCasesToMemoryAndCache(fetchedCasesByCourtDate, false);
978
+ }
979
+ + // Fallback: old-month court dates excluded by the windowed bulk query
980
+ + const unfound = scarMissingKeys.filter((k) => !foundKeys.has(k));
981
+ + if (unfound.length > 0) {
982
+ + await Promise.all(
983
+ + unfound.map(async (key) => {
984
+ + const date = dateByKey.get(key)!;
985
+ + const fetched = await fetchCasesByCourtDate(date.CourtDateID.toString(), apiKey);
986
+ + fetchedCasesByCourtDate[key] = fetched ?? [];
987
+ + }),
988
+ + );
989
+ + await addPartialCasesToMemoryAndCache(fetchedCasesByCourtDate, false);
990
+ + }
991
+ } else {
992
+ await Promise.all(
993
+ scarMissingKeys.map(async (key) => {
994
+ diff --git a/src/hooks/UseCourtDates.ts b/src/hooks/UseCourtDates.ts
995
+ index 5fe7e37..175ce07 100644
996
+ --- a/src/hooks/UseCourtDates.ts
997
+ +++ b/src/hooks/UseCourtDates.ts
998
+ @@ -1,8 +1,8 @@
999
+ -import { useState, useEffect, useRef, useCallback, type Dispatch, type SetStateAction } from 'react';
1000
+ +import { useState, useEffect, useRef, useCallback, useMemo, type Dispatch, type SetStateAction } from 'react';
1001
+ import { DateType, type CourtDate } from '@/types';
1002
+ import { getAllDates, updateCourtDate } from '@/helpers/api/courtDates';
1003
+ import { getCourtDatesCache, setCourtDatesCache, getNegotiationsCache, setNegotiationsCache, getCollectionsCache, setCollectionsCache, clearAllCache } from '@/helpers/cache';
1004
+ -import { COURT_DATES_POLL_INTERVAL } from '@/constants';
1005
+ +import { COURT_DATES_POLL_INTERVAL, HOT_WINDOW_DAYS, WARM_CACHE_TTL_MS } from '@/constants';
1006
+
1007
+ /**
1008
+ * Loads, caches, and mutates the full list of court dates.
1009
+ @@ -45,6 +45,20 @@ export function useCourtDates({
1010
+ const pollSkippedRef = useRef(false);
1011
+ const fetchCountRef = useRef(0);
1012
+
1013
+ + // Rolling window: only fetch dates from HOT_WINDOW_DAYS ago onward on initial load / polls.
1014
+ + // Older months are loaded on demand via loadRange() when the user navigates back.
1015
+ + const windowStart = useMemo(() => {
1016
+ + const d = new Date();
1017
+ + d.setDate(d.getDate() - HOT_WINDOW_DAYS);
1018
+ + return d.toISOString().slice(0, 10);
1019
+ + }, []);
1020
+ + // YYYY-MM of the earliest month covered by the hot window — months before this need loadRange
1021
+ + const windowStartMonth = useMemo(() => windowStart.slice(0, 7), [windowStart]);
1022
+ + // Track which out-of-window months have already been fetched this session
1023
+ + const loadedMonthsRef = useRef<Set<string>>(new Set());
1024
+ + // Track months currently being fetched — prevents duplicate in-flight requests
1025
+ + const fetchingMonthsRef = useRef<Set<string>>(new Set());
1026
+ +
1027
+ const normalizeDates = useCallback((dates: CourtDate[]): CourtDate[] => {
1028
+ return dates.map((d: CourtDate & { Date?: string; Time?: string; NegDateID?: number }) => ({
1029
+ ...d,
1030
+ @@ -54,6 +68,11 @@ export function useCourtDates({
1031
+ MultipleDates: Array.isArray(d.MultipleDates)
1032
+ ? d.MultipleDates.map((md: Date | string) => (md instanceof Date ? md : new Date(md as string)))
1033
+ : null,
1034
+ + NoticeFile: Array.isArray(d.NoticeFile)
1035
+ + ? d.NoticeFile
1036
+ + : typeof d.NoticeFile === 'string' && d.NoticeFile
1037
+ + ? [d.NoticeFile]
1038
+ + : null,
1039
+ }));
1040
+ }, []);
1041
+
1042
+ @@ -73,13 +92,20 @@ export function useCourtDates({
1043
+ const getAllCourtDates = useCallback(async (skipCache = false) => {
1044
+ const cachedDates = skipCache ? null : await getCourtDatesCache();
1045
+ if (cachedDates && cachedDates.length > 0) {
1046
+ - setCourtDates(normalizeDates(cachedDates));
1047
+ + setCourtDates((prev) => {
1048
+ + const normalized = normalizeDates(cachedDates);
1049
+ + const newIds = new Set(normalized.map((d) => d.CourtDateID));
1050
+ + return [...normalized, ...prev.filter((d) => !newIds.has(d.CourtDateID))];
1051
+ + });
1052
+ console.log('%c📅 CourtDates', 'font-weight:bold', `💾 ${cachedDates.length} from cache`);
1053
+ } else {
1054
+ startFetch();
1055
+ - getAllDates(apiKey)
1056
+ + getAllDates(apiKey, undefined, windowStart)
1057
+ .then(async (dates) => {
1058
+ - setCourtDates(dates);
1059
+ + setCourtDates((prev) => {
1060
+ + const newIds = new Set(dates.map((d) => d.CourtDateID));
1061
+ + return [...dates, ...prev.filter((d) => !newIds.has(d.CourtDateID))];
1062
+ + });
1063
+ await setCourtDatesCache(dates);
1064
+ console.log('%c📅 CourtDates', 'font-weight:bold', `🌐 ${dates.length} from API`);
1065
+ })
1066
+ @@ -88,18 +114,25 @@ export function useCourtDates({
1067
+ })
1068
+ .finally(endFetch);
1069
+ }
1070
+ - }, [apiKey, normalizeDates, startFetch, endFetch]);
1071
+ + }, [apiKey, windowStart, normalizeDates, startFetch, endFetch]);
1072
+
1073
+ const getAllNegotiations = useCallback(async (skipCache = false) => {
1074
+ const cachedDates = skipCache ? null : await getNegotiationsCache();
1075
+ if (cachedDates && cachedDates.length > 0) {
1076
+ - setNegotiations(normalizeDates(cachedDates));
1077
+ + setNegotiations((prev) => {
1078
+ + const normalized = normalizeDates(cachedDates);
1079
+ + const newIds = new Set(normalized.map((d) => d.CourtDateID));
1080
+ + return [...normalized, ...prev.filter((d) => !newIds.has(d.CourtDateID))];
1081
+ + });
1082
+ console.log('%c🤝 Negotiations', 'font-weight:bold', `💾 ${cachedDates.length} from cache`);
1083
+ } else {
1084
+ startFetch();
1085
+ - getAllDates(apiKey, DateType.NEGOTIATIONS)
1086
+ + getAllDates(apiKey, DateType.NEGOTIATIONS, windowStart)
1087
+ .then(async (dates) => {
1088
+ - setNegotiations(dates);
1089
+ + setNegotiations((prev) => {
1090
+ + const newIds = new Set(dates.map((d) => d.CourtDateID));
1091
+ + return [...dates, ...prev.filter((d) => !newIds.has(d.CourtDateID))];
1092
+ + });
1093
+ await setNegotiationsCache(dates);
1094
+ console.log('%c🤝 Negotiations', 'font-weight:bold', `🌐 ${dates.length} from API`);
1095
+ })
1096
+ @@ -108,18 +141,25 @@ export function useCourtDates({
1097
+ })
1098
+ .finally(endFetch);
1099
+ }
1100
+ - }, [apiKey, normalizeDates, startFetch, endFetch]);
1101
+ + }, [apiKey, windowStart, normalizeDates, startFetch, endFetch]);
1102
+
1103
+ const getAllCollections = useCallback(async (skipCache = false) => {
1104
+ const cachedDates = skipCache ? null : await getCollectionsCache();
1105
+ if (cachedDates && cachedDates.length > 0) {
1106
+ - setCollections(normalizeDates(cachedDates));
1107
+ + setCollections((prev) => {
1108
+ + const normalized = normalizeDates(cachedDates);
1109
+ + const newIds = new Set(normalized.map((d) => d.CourtDateID));
1110
+ + return [...normalized, ...prev.filter((d) => !newIds.has(d.CourtDateID))];
1111
+ + });
1112
+ console.log('%c📋 Collections', 'font-weight:bold', `💾 ${cachedDates.length} from cache`);
1113
+ } else {
1114
+ startFetch();
1115
+ - getAllDates(apiKey, DateType.COLLECTIONS)
1116
+ + getAllDates(apiKey, DateType.COLLECTIONS, windowStart)
1117
+ .then(async (dates) => {
1118
+ - setCollections(dates);
1119
+ + setCollections((prev) => {
1120
+ + const newIds = new Set(dates.map((d) => d.CourtDateID));
1121
+ + return [...dates, ...prev.filter((d) => !newIds.has(d.CourtDateID))];
1122
+ + });
1123
+ await setCollectionsCache(dates);
1124
+ console.log('%c📋 Collections', 'font-weight:bold', `🌐 ${dates.length} from API`);
1125
+ })
1126
+ @@ -128,7 +168,69 @@ export function useCourtDates({
1127
+ })
1128
+ .finally(endFetch);
1129
+ }
1130
+ - }, [apiKey, normalizeDates, startFetch, endFetch]);
1131
+ + }, [apiKey, windowStart, normalizeDates, startFetch, endFetch]);
1132
+ +
1133
+ + // Load a specific date range on demand — called when the user navigates the calendar
1134
+ + // backward past the hot window. Merges results into state without replacing it.
1135
+ + const loadRange = useCallback(async (from: Date, to: Date) => {
1136
+ + const toLoad: string[] = [];
1137
+ + const cursor = new Date(from.getFullYear(), from.getMonth(), 1);
1138
+ + const end = new Date(to.getFullYear(), to.getMonth(), 1);
1139
+ + while (cursor <= end) {
1140
+ + const key = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}`;
1141
+ + // Skip months already covered by the hot window, successfully loaded, or in-flight
1142
+ + if (key >= windowStartMonth || loadedMonthsRef.current.has(key) || fetchingMonthsRef.current.has(key)) {
1143
+ + cursor.setMonth(cursor.getMonth() + 1);
1144
+ + continue;
1145
+ + }
1146
+ + toLoad.push(key);
1147
+ + cursor.setMonth(cursor.getMonth() + 1);
1148
+ + }
1149
+ + if (toLoad.length === 0) return;
1150
+ +
1151
+ + // Mark in-flight before the await so concurrent calls skip these months
1152
+ + toLoad.forEach((k) => fetchingMonthsRef.current.add(k));
1153
+ +
1154
+ + const dateFrom = `${toLoad[0]}-01`;
1155
+ + const [ly, lm] = toLoad[toLoad.length - 1].split('-').map(Number);
1156
+ + const dateTo = new Date(ly, lm, 0).toISOString().slice(0, 10); // last day of last month
1157
+ +
1158
+ + try {
1159
+ + const [newDates, newNegs, newCols] = await Promise.all([
1160
+ + getAllDates(apiKey, undefined, dateFrom, dateTo),
1161
+ + getAllDates(apiKey, DateType.NEGOTIATIONS, dateFrom, dateTo),
1162
+ + getAllDates(apiKey, DateType.COLLECTIONS, dateFrom, dateTo),
1163
+ + ]);
1164
+ + const merge = (prev: CourtDate[], incoming: CourtDate[]) => {
1165
+ + if (incoming.length === 0) return prev;
1166
+ + const seen = new Set(prev.map((d) => d.CourtDateID));
1167
+ + return [...prev, ...normalizeDates(incoming).filter((d) => !seen.has(d.CourtDateID))];
1168
+ + };
1169
+ + setCourtDates((prev) => {
1170
+ + const merged = merge(prev, newDates);
1171
+ + queueMicrotask(() => setCourtDatesCache(merged, WARM_CACHE_TTL_MS));
1172
+ + return merged;
1173
+ + });
1174
+ + setNegotiations((prev) => {
1175
+ + const merged = merge(prev, newNegs);
1176
+ + queueMicrotask(() => setNegotiationsCache(merged, WARM_CACHE_TTL_MS));
1177
+ + return merged;
1178
+ + });
1179
+ + setCollections((prev) => {
1180
+ + const merged = merge(prev, newCols);
1181
+ + queueMicrotask(() => setCollectionsCache(merged, WARM_CACHE_TTL_MS));
1182
+ + return merged;
1183
+ + });
1184
+ + toLoad.forEach((k) => {
1185
+ + loadedMonthsRef.current.add(k);
1186
+ + fetchingMonthsRef.current.delete(k);
1187
+ + });
1188
+ + console.log('%c🔙 loadRange', 'font-weight:bold', `${dateFrom} → ${dateTo} | +${newDates.length}/${newNegs.length}/${newCols.length}`);
1189
+ + } catch (error) {
1190
+ + toLoad.forEach((k) => fetchingMonthsRef.current.delete(k)); // allow retry
1191
+ + console.error('❌ loadRange failed:', error);
1192
+ + }
1193
+ + }, [apiKey, windowStartMonth, normalizeDates]);
1194
+
1195
+ useEffect(() => {
1196
+ getAllCourtDates();
1197
+ @@ -238,9 +340,11 @@ export function useCourtDates({
1198
+ }
1199
+
1200
+ async function forceRefreshDates() {
1201
+ + loadedMonthsRef.current.clear();
1202
+ + fetchingMonthsRef.current.clear();
1203
+ await clearAllCache();
1204
+ - await Promise.all([getAllCourtDates(), getAllNegotiations(), getAllCollections()]);
1205
+ + await Promise.all([getAllCourtDates(true), getAllNegotiations(true), getAllCollections(true)]);
1206
+ }
1207
+
1208
+ - return { courtDates, negotiations, collections, isFetchingDates, lastFetchedAt, forceRefreshDates, updateCourtDateInMemory, handleUpdateChair };
1209
+ + return { courtDates, negotiations, collections, isFetchingDates, lastFetchedAt, forceRefreshDates, updateCourtDateInMemory, handleUpdateChair, loadRange };
1210
+ }
1211
+ diff --git a/src/hooks/UseModalActions.ts b/src/hooks/UseModalActions.ts
1212
+ index 133918f..935174a 100644
1213
+ --- a/src/hooks/UseModalActions.ts
1214
+ +++ b/src/hooks/UseModalActions.ts
1215
+ @@ -51,9 +51,18 @@ export function useModalActions({
1216
+ const files = pendingNoticeFiles ?? [];
1217
+
1218
+ function refreshNoticeFiles(courtDateId: number, type?: DateType) {
1219
+ - getCourtDate(courtDateId, apiKey, type).then((fresh) => {
1220
+ - if (fresh?.CourtDateID) updateCourtDateInMemory(fresh);
1221
+ - });
1222
+ + getCourtDate(courtDateId, apiKey, type)
1223
+ + .then((fresh) => {
1224
+ + if (fresh?.CourtDateID) {
1225
+ + updateCourtDateInMemory({
1226
+ + ...fresh,
1227
+ + NoticeFile: fresh.NoticeFile ?? selectedCourtDate?.NoticeFile ?? null,
1228
+ + });
1229
+ + }
1230
+ + })
1231
+ + .catch((err) => {
1232
+ + console.error('❌ Failed to refresh notice files after upload:', err);
1233
+ + });
1234
+ }
1235
+ function validateEditedData(): { valid: boolean; message?: string } {
1236
+ if (!editedData) return { valid: false, message: 'No data to validate' };
1237
+ @@ -116,7 +125,7 @@ export function useModalActions({
1238
+ if (id) {
1239
+ const updatedData = { ...editedData, CourtDateID: id };
1240
+ setEditedData(updatedData);
1241
+ - await updateCourtDate(id, updatedData, apiKey, undefined, undefined, updatedData.DateType ?? undefined);
1242
+ + await updateCourtDate(id, { ...updatedData, NoticeFile: undefined }, apiKey, undefined, undefined, updatedData.DateType ?? undefined);
1243
+ updateCourtDateInMemory(updatedData);
1244
+ updateCases({ [caseKey(updatedData)]: editedCases });
1245
+ if (files.length > 0) refreshNoticeFiles(id, editedData.DateType ?? undefined);