Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions frontend/src/components/app/SearchResult.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -66,8 +67,8 @@ const SearchResult: React.FC<SearchResultProps> = ({ searchResult: sr }) => {
<p>{summary.court}</p>
{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:'
Expand All @@ -77,7 +78,7 @@ const SearchResult: React.FC<SearchResultProps> = ({ searchResult: sr }) => {

return (
<p className="mt-1 text-sm text-gray-600">
<span className="font-medium">{label}</span> {d.toLocaleDateString()}
<span className="font-medium">{label}</span> {formatDisplayDate(d)}
</p>
);
}
Expand All @@ -103,15 +104,15 @@ const SearchResult: React.FC<SearchResultProps> = ({ searchResult: sr }) => {
<div>
<span className="font-medium">Filed:</span>{' '}
{(() => {
const d = new Date(charge.filedDate);
return isNaN(d.getTime()) ? '' : d.toLocaleDateString();
const d = parseDateString(charge.filedDate);
return d ? formatDisplayDate(d) : '';
})()}
</div>
<div>
<span className="font-medium">Offense:</span>{' '}
{(() => {
const d = new Date(charge.offenseDate);
return isNaN(d.getTime()) ? '' : d.toLocaleDateString();
const d = parseDateString(charge.offenseDate);
return d ? formatDisplayDate(d) : '';
})()}
</div>
<div>
Expand All @@ -138,8 +139,9 @@ const SearchResult: React.FC<SearchResultProps> = ({ searchResult: sr }) => {
<span className="font-medium">Disposition:</span>{' '}
{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) : '';
})()}
)
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
},
Expand Down
1 change: 1 addition & 0 deletions serverless/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>', '<rootDir>/../shared'],
testMatch: ['**/__tests__/**/*.test.ts'],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
collectCoverage: true,
Expand Down
29 changes: 5 additions & 24 deletions serverless/lib/CaseProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -521,27 +522,6 @@ const caseEndpoints: Record<string, EndpointConfig> = {
},
};

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<CaseSummary | null> {
try {
const portalCaseUrl = process.env.PORTAL_CASE_URL;
Expand Down Expand Up @@ -813,7 +793,7 @@ function buildCaseSummary(rawData: Record<string, PortalApiResponse>): CaseSumma
return;
}

const parsed = parseMMddyyyyToDate(eventDateStr);
const parsed = parseUsDate(eventDateStr);
if (parsed) {
parsedCandidates.push({
date: parsed,
Expand All @@ -832,7 +812,8 @@ function buildCaseSummary(rawData: Record<string, PortalApiResponse>): 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 {
Expand Down
30 changes: 17 additions & 13 deletions serverless/lib/__tests__/CaseSearchProcessor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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,
};
Expand All @@ -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
};
Expand Down Expand Up @@ -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,
};
Expand All @@ -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,
};
Expand All @@ -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,
};
Expand All @@ -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,
};
Expand All @@ -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,
};
Expand All @@ -226,7 +230,7 @@ describe('CaseSearchProcessor', () => {
caseNumber: '22CR123456-789',
caseId: undefined,
fetchStatus: { status: 'notFound' },
lastUpdated: new Date().toISOString(),
lastUpdated: lastUpdatedAfterVersion(),
} as ZipCase,
caseSummary: undefined,
};
Expand All @@ -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,
};
Expand Down Expand Up @@ -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
},
Expand All @@ -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
},
Expand All @@ -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,
},
Expand Down Expand Up @@ -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,
};
Expand Down
10 changes: 5 additions & 5 deletions serverless/lib/__tests__/caseProcessor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
2 changes: 1 addition & 1 deletion serverless/lib/__tests__/storageClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};

Expand Down
2 changes: 1 addition & 1 deletion serverless/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@
"outDir": ".build",
"typeRoots": ["./node_modules/@types", "./types"]
},
"include": ["**/*.ts"],
"include": ["**/*.ts", "../shared/**/*.ts"],
"exclude": ["node_modules"]
}
50 changes: 50 additions & 0 deletions shared/DateTimeUtils.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading