diff --git a/frontend/src/components/app/SearchResult.tsx b/frontend/src/components/app/SearchResult.tsx index 7e6b83b..0f750de 100644 --- a/frontend/src/components/app/SearchResult.tsx +++ b/frontend/src/components/app/SearchResult.tsx @@ -1,5 +1,6 @@ import React from 'react'; import type { SearchResult as SearchResultType } from '../../../../shared/types'; +import { parseDateString, formatDisplayDate } from '../../../../shared/DateTimeUtils'; import SearchStatus from './SearchStatus'; import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline'; import { PORTAL_CASE_URL } from '../../aws-exports'; @@ -66,8 +67,8 @@ const SearchResult: React.FC = ({ searchResult: sr }) => {

{summary.court}

{summary.arrestOrCitationDate && (() => { - const d = new Date(summary.arrestOrCitationDate); - if (!isNaN(d.getTime())) { + const d = parseDateString(summary.arrestOrCitationDate); + if (d) { const label = summary.arrestOrCitationType === 'Arrest' ? 'Arrest Date:' @@ -77,7 +78,7 @@ const SearchResult: React.FC = ({ searchResult: sr }) => { return (

- {label} {d.toLocaleDateString()} + {label} {formatDisplayDate(d)}

); } @@ -103,15 +104,15 @@ const SearchResult: React.FC = ({ searchResult: sr }) => {
Filed:{' '} {(() => { - const d = new Date(charge.filedDate); - return isNaN(d.getTime()) ? '' : d.toLocaleDateString(); + const d = parseDateString(charge.filedDate); + return d ? formatDisplayDate(d) : ''; })()}
Offense:{' '} {(() => { - const d = new Date(charge.offenseDate); - return isNaN(d.getTime()) ? '' : d.toLocaleDateString(); + const d = parseDateString(charge.offenseDate); + return d ? formatDisplayDate(d) : ''; })()}
@@ -138,8 +139,9 @@ const SearchResult: React.FC = ({ searchResult: sr }) => { Disposition:{' '} {charge.dispositions[0].description} ( {(() => { - const d = new Date(charge.dispositions[0].date); - return isNaN(d.getTime()) ? '' : d.toLocaleDateString(); + const dispDate = charge.dispositions[0].date; + const d = parseDateString(dispDate); + return d ? formatDisplayDate(d) : ''; })()} )
diff --git a/frontend/src/components/app/__tests__/SearchResult.test.tsx b/frontend/src/components/app/__tests__/SearchResult.test.tsx index 907ee93..876f43b 100644 --- a/frontend/src/components/app/__tests__/SearchResult.test.tsx +++ b/frontend/src/components/app/__tests__/SearchResult.test.tsx @@ -178,7 +178,7 @@ describe('SearchResult component', () => { caseSummary: { caseName: 'State vs. Doe', court: 'Circuit Court', - arrestOrCitationDate: '2022-02-15T00:00:00Z', + arrestOrCitationDate: '2022-02-15', arrestOrCitationType: 'Arrest', charges: [], }, diff --git a/serverless/jest.config.js b/serverless/jest.config.js index a87b2aa..b9140da 100644 --- a/serverless/jest.config.js +++ b/serverless/jest.config.js @@ -2,6 +2,7 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', + roots: ['', '/../shared'], testMatch: ['**/__tests__/**/*.test.ts'], setupFilesAfterEnv: ['/jest.setup.ts'], collectCoverage: true, diff --git a/serverless/lib/CaseProcessor.ts b/serverless/lib/CaseProcessor.ts index 96f1da6..ee85db3 100644 --- a/serverless/lib/CaseProcessor.ts +++ b/serverless/lib/CaseProcessor.ts @@ -8,11 +8,12 @@ import { CaseSummary, Charge, Disposition, FetchStatus } from '../../shared/type import { CookieJar } from 'tough-cookie'; import axios from 'axios'; import { wrapper } from 'axios-cookiejar-support'; +import { parseUsDate, formatIsoDate } from '../../shared/DateTimeUtils'; import * as cheerio from 'cheerio'; // Version date used to determine whether a cached 'complete' CaseSummary is // up-to-date or should be re-fetched to align with current schema/logic. -export const CASE_SUMMARY_VERSION_DATE = new Date('2025-10-06T00:00:00Z'); +export const CASE_SUMMARY_VERSION_DATE = new Date('2025-10-08T14:00:00Z'); // Type for raw portal JSON data - using `any` is acceptable here since we're dealing with // dynamic external API responses that we don't control @@ -521,27 +522,6 @@ const caseEndpoints: Record = { }, }; -function parseMMddyyyyToDate(dateStr: string): Date | null { - if (!dateStr || typeof dateStr !== 'string') { - return null; - } - - const parts = dateStr.split('/'); - if (parts.length !== 3) { - return null; - } - - const month = parseInt(parts[0], 10); - const day = parseInt(parts[1], 10); - const year = parseInt(parts[2], 10); - - if (Number.isNaN(month) || Number.isNaN(day) || Number.isNaN(year)) { - return null; - } - - return new Date(Date.UTC(year, month - 1, day)); -} - async function fetchCaseSummary(caseId: string): Promise { try { const portalCaseUrl = process.env.PORTAL_CASE_URL; @@ -813,7 +793,7 @@ function buildCaseSummary(rawData: Record): CaseSumma return; } - const parsed = parseMMddyyyyToDate(eventDateStr); + const parsed = parseUsDate(eventDateStr); if (parsed) { parsedCandidates.push({ date: parsed, @@ -832,7 +812,8 @@ function buildCaseSummary(rawData: Record): CaseSumma (min, c) => (c.date.getTime() < min.date.getTime() ? c : min), parsedCandidates[0] ); - caseSummary.arrestOrCitationDate = earliest.date.toISOString(); + + caseSummary.arrestOrCitationDate = formatIsoDate(earliest.date); caseSummary.arrestOrCitationType = earliest.type; console.log(`🔔 Set ${earliest.type} date to ${caseSummary.arrestOrCitationDate}`); } else { diff --git a/serverless/lib/__tests__/CaseSearchProcessor.test.ts b/serverless/lib/__tests__/CaseSearchProcessor.test.ts index 9e5b3bd..ca86bf0 100644 --- a/serverless/lib/__tests__/CaseSearchProcessor.test.ts +++ b/serverless/lib/__tests__/CaseSearchProcessor.test.ts @@ -6,6 +6,9 @@ import StorageClient from '../StorageClient'; import QueueClient from '../QueueClient'; import PortalAuthenticator from '../PortalAuthenticator'; import { SearchResult, CaseSearchRequest, ZipCase, CaseSummary } from '../../../shared/types'; +import { CASE_SUMMARY_VERSION_DATE } from '../CaseProcessor'; + +const lastUpdatedAfterVersion = (offsetMs = 1000): string => new Date(CASE_SUMMARY_VERSION_DATE.getTime() + offsetMs).toISOString(); // Mock dependencies jest.mock('../StorageClient'); @@ -48,7 +51,8 @@ describe('CaseSearchProcessor', () => { caseNumber: '22CR123456-789', caseId: 'test-case-id', fetchStatus: { status: 'complete' }, - lastUpdated: new Date().toISOString(), + // Ensure lastUpdated is after CASE_SUMMARY_VERSION_DATE so tests treat the summary as up-to-date + lastUpdated: lastUpdatedAfterVersion(), } as ZipCase, caseSummary, }; @@ -71,7 +75,7 @@ describe('CaseSearchProcessor', () => { caseNumber: '22CR123456-789', caseId: 'test-case-id', fetchStatus: { status: 'complete' }, - lastUpdated: new Date().toISOString(), + lastUpdated: lastUpdatedAfterVersion(), } as ZipCase, caseSummary: undefined, // Missing summary }; @@ -107,7 +111,7 @@ describe('CaseSearchProcessor', () => { caseNumber: '22CR123456-789', caseId: undefined, // Missing caseId fetchStatus: { status: 'complete' }, - lastUpdated: new Date().toISOString(), + lastUpdated: lastUpdatedAfterVersion(), } as ZipCase, caseSummary: undefined, }; @@ -131,7 +135,7 @@ describe('CaseSearchProcessor', () => { caseNumber: '22CR123456-789', caseId: 'test-case-id', fetchStatus: { status: 'found' }, - lastUpdated: new Date().toISOString(), + lastUpdated: lastUpdatedAfterVersion(), } as ZipCase, caseSummary: undefined, }; @@ -155,7 +159,7 @@ describe('CaseSearchProcessor', () => { caseNumber: '22CR123456-789', caseId: 'test-case-id', fetchStatus: { status: 'reprocessing', tryCount: 1 }, - lastUpdated: new Date().toISOString(), + lastUpdated: lastUpdatedAfterVersion(), } as ZipCase, caseSummary: undefined, }; @@ -179,7 +183,7 @@ describe('CaseSearchProcessor', () => { caseNumber: '22CR123456-789', caseId: undefined, // Missing caseId fetchStatus: { status: 'found' }, - lastUpdated: new Date().toISOString(), + lastUpdated: lastUpdatedAfterVersion(), } as ZipCase, caseSummary: undefined, }; @@ -203,7 +207,7 @@ describe('CaseSearchProcessor', () => { caseNumber: '22CR123456-789', caseId: 'test-case-id', fetchStatus: { status: 'processing' }, - lastUpdated: new Date().toISOString(), + lastUpdated: lastUpdatedAfterVersion(), } as ZipCase, caseSummary: undefined, }; @@ -226,7 +230,7 @@ describe('CaseSearchProcessor', () => { caseNumber: '22CR123456-789', caseId: undefined, fetchStatus: { status: 'notFound' }, - lastUpdated: new Date().toISOString(), + lastUpdated: lastUpdatedAfterVersion(), } as ZipCase, caseSummary: undefined, }; @@ -249,7 +253,7 @@ describe('CaseSearchProcessor', () => { caseNumber: '22CR123456-789', caseId: undefined, fetchStatus: { status: 'failed', message: 'Test failure' }, - lastUpdated: new Date().toISOString(), + lastUpdated: lastUpdatedAfterVersion(), } as ZipCase, caseSummary: undefined, }; @@ -294,7 +298,7 @@ describe('CaseSearchProcessor', () => { caseNumber: '22CR123456-789', caseId: 'case-id-1', fetchStatus: { status: 'complete' }, - lastUpdated: new Date().toISOString(), + lastUpdated: lastUpdatedAfterVersion(), } as ZipCase, caseSummary, // Has summary - truly complete }, @@ -303,7 +307,7 @@ describe('CaseSearchProcessor', () => { caseNumber: '23CV654321-456', caseId: 'case-id-2', fetchStatus: { status: 'complete' }, - lastUpdated: new Date().toISOString(), + lastUpdated: lastUpdatedAfterVersion(), } as ZipCase, caseSummary: undefined, // Missing summary - should be treated as found }, @@ -312,7 +316,7 @@ describe('CaseSearchProcessor', () => { caseNumber: '24CV789012-345', caseId: 'case-id-3', fetchStatus: { status: 'found' }, - lastUpdated: new Date().toISOString(), + lastUpdated: lastUpdatedAfterVersion(), } as ZipCase, caseSummary: undefined, }, @@ -363,7 +367,7 @@ describe('CaseSearchProcessor', () => { caseNumber: '22CR123456-789', caseId: 'test-case-id', fetchStatus: { status: 'found' }, - lastUpdated: new Date().toISOString(), + lastUpdated: lastUpdatedAfterVersion(), } as ZipCase, caseSummary: undefined, }; diff --git a/serverless/lib/__tests__/caseProcessor.test.ts b/serverless/lib/__tests__/caseProcessor.test.ts index a0b07d7..92b9dec 100644 --- a/serverless/lib/__tests__/caseProcessor.test.ts +++ b/serverless/lib/__tests__/caseProcessor.test.ts @@ -188,9 +188,9 @@ describe('CaseProcessor', () => { expect(summary?.arrestOrCitationDate).toBeDefined(); expect(summary?.arrestOrCitationType).toBe('Arrest'); - // Expected earliest LPSD date is 02/10/2021 -> construct UTC Date and compare ISO - const expectedIso = new Date(Date.UTC(2021, 1, 10)).toISOString(); - expect(summary?.arrestOrCitationDate).toBe(expectedIso); + // Expected earliest LPSD date is 02/10/2021 -> expect date-only string + const expectedDateOnly = '2021-02-10'; + expect(summary?.arrestOrCitationDate).toBe(expectedDateOnly); }); it('selects CIT over LPSD if earlier (sets type Citation)', () => { @@ -218,8 +218,8 @@ describe('CaseProcessor', () => { expect(summary?.arrestOrCitationDate).toBeDefined(); expect(summary?.arrestOrCitationType).toBe('Citation'); - const expectedIso = new Date(Date.UTC(2021, 1, 9)).toISOString(); - expect(summary?.arrestOrCitationDate).toBe(expectedIso); + const expectedDateOnly = '2021-02-09'; + expect(summary?.arrestOrCitationDate).toBe(expectedDateOnly); }); it('does not set arrestOrCitationDate when no LPSD/CIT events present', () => { diff --git a/serverless/lib/__tests__/storageClient.test.ts b/serverless/lib/__tests__/storageClient.test.ts index aa71776..f6c229e 100644 --- a/serverless/lib/__tests__/storageClient.test.ts +++ b/serverless/lib/__tests__/storageClient.test.ts @@ -453,7 +453,7 @@ describe('StorageClient.getSearchResults resilience', () => { caseName: 'State vs Arrested', court: 'Test Court', charges: [], - arrestOrCitationDate: '2021-02-10T00:00:00.000Z', + arrestOrCitationDate: '2021-02-10', arrestOrCitationType: 'Arrest', }; diff --git a/serverless/tsconfig.json b/serverless/tsconfig.json index 4133be3..22c9675 100644 --- a/serverless/tsconfig.json +++ b/serverless/tsconfig.json @@ -11,6 +11,6 @@ "outDir": ".build", "typeRoots": ["./node_modules/@types", "./types"] }, - "include": ["**/*.ts"], + "include": ["**/*.ts", "../shared/**/*.ts"], "exclude": ["node_modules"] } diff --git a/shared/DateTimeUtils.ts b/shared/DateTimeUtils.ts new file mode 100644 index 0000000..043ca92 --- /dev/null +++ b/shared/DateTimeUtils.ts @@ -0,0 +1,50 @@ +/** + * Parse a US-style date string (MM/dd/yyyy) into a UTC Date at midnight. + * Returns null when input is falsy or malformed. + */ +export function parseUsDate(dateStr: string | null | undefined): Date | null { + if (!dateStr || typeof dateStr !== 'string') return null; + + const parts = dateStr.split('/'); + if (parts.length !== 3) return null; + + const month = parseInt(parts[0], 10); + const day = parseInt(parts[1], 10); + const year = parseInt(parts[2], 10); + + if (Number.isNaN(month) || Number.isNaN(day) || Number.isNaN(year)) return null; + + return new Date(Date.UTC(year, month - 1, day)); +} + +export function formatIsoDate(date: Date): string { + return date.toISOString().slice(0, 10); +} + +export function formatDisplayDate(date: Date, locale?: string): string { + return date.toLocaleDateString(locale ?? undefined, { timeZone: 'UTC' }); +} + +/** + * Parse a flexible date string into a UTC Date at midnight where appropriate. + * - If input is YYYY-MM-DD -> treat as UTC date at midnight + * - If input is MM/dd/yyyy -> use parseUsDate + * - Otherwise, attempt `new Date(input)` and return only if valid + */ +export function parseDateString(dateStr: string | null | undefined): Date | null { + if (!dateStr || typeof dateStr !== 'string') return null; + + // YYYY-MM-DD (date-only) -> UTC midnight + if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { + return new Date(dateStr + 'T00:00:00Z'); + } + + // MM/dd/yyyy -> portal US date format + if (/^\d{1,2}\/\d{1,2}\/\d{4}$/.test(dateStr)) { + return parseUsDate(dateStr); + } + + // Otherwise try built-in parsing (ISO timestamps etc.) + const d = new Date(dateStr); + return isNaN(d.getTime()) ? null : d; +} diff --git a/shared/__tests__/DateTimeUtils.test.ts b/shared/__tests__/DateTimeUtils.test.ts new file mode 100644 index 0000000..5e9f71f --- /dev/null +++ b/shared/__tests__/DateTimeUtils.test.ts @@ -0,0 +1,56 @@ +import { parseUsDate, formatDisplayDate, formatIsoDate, parseDateString } from '../DateTimeUtils'; + +describe('DateTimeUtils', () => { + it('parses US dates (MM/dd/yyyy) to UTC midnight', () => { + const d = parseUsDate('02/10/2021'); + expect(d).not.toBeNull(); + if (d) { + expect(d.getUTCFullYear()).toBe(2021); + expect(d.getUTCMonth()).toBe(1); // February = 1 + expect(d.getUTCDate()).toBe(10); + } + }); + + it('formats a Date to ISO YYYY-MM-DD', () => { + const d = new Date(Date.UTC(2021, 1, 10)); + expect(formatIsoDate(d)).toBe('2021-02-10'); + }); + + it('formats a Date for display in current locale using UTC day', () => { + const d = new Date(Date.UTC(2021, 1, 10)); + const expected = d.toLocaleDateString(undefined, { timeZone: 'UTC' }); + expect(formatDisplayDate(d)).toBe(expected); + }); + + it('parses date-only strings (YYYY-MM-DD) as UTC midnight', () => { + const d = parseDateString('2021-02-10'); + expect(d).not.toBeNull(); + if (d) { + expect(d.toISOString().startsWith('2021-02-10')).toBeTruthy(); + } + }); + + it('parses ISO timestamps', () => { + const d = parseDateString('2021-02-10T05:00:00Z'); + expect(d).not.toBeNull(); + if (d) { + expect(d.toISOString()).toBe('2021-02-10T05:00:00.000Z'); + } + }); + + it('parses US format via parseDateInput', () => { + const d = parseDateString('03/15/2021'); + expect(d).not.toBeNull(); + if (d) { + expect(d.getUTCFullYear()).toBe(2021); + expect(d.getUTCMonth()).toBe(2); // March + expect(d.getUTCDate()).toBe(15); + } + }); + + it('returns null for invalid inputs', () => { + expect(parseDateString('not-a-date')).toBeNull(); + expect(parseDateString('')).toBeNull(); + expect(parseDateString(null)).toBeNull(); + }); +});