Skip to content

Commit 529611d

Browse files
authored
feat: Resolve zip code to city and state (#119)
1 parent 4fcca1e commit 529611d

6 files changed

Lines changed: 178 additions & 48 deletions

File tree

frontend/package-lock.json

Lines changed: 16 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"class-variance-authority": "^0.7.1",
4343
"clsx": "^2.1.1",
4444
"lodash": "^4.17.21",
45+
"lru-cache": "^10.4.3",
4546
"lucide-react": "^0.544.0",
4647
"next": "^15.1.1",
4748
"next-intl": "^3.2.1",

frontend/src/app/[locale]/generate-referrals/page.tsx

Lines changed: 91 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
77
import { fetchResources } from "@/util/fetchResources";
88
import { Resource } from "@/types/resources";
99
import "@/app/globals.css";
10+
import { fetchLocationFromZip } from "@/util/fetchLocation";
1011

1112
import { PrintableReferralsReport } from "@/util/printReferrals";
1213
import { fetchActionPlan, ActionPlan } from "@/util/fetchActionPlan";
@@ -48,6 +49,8 @@ export default function Page() {
4849
const [errorMessage, setErrorMessage] = useState<string | undefined>(
4950
undefined,
5051
);
52+
const [requestAfterZipResolution, setRequestAfterZipResolution] =
53+
useState("");
5154

5255
const searchParams = useSearchParams();
5356

@@ -101,14 +104,15 @@ export default function Page() {
101104
setReadyToPrint(true);
102105
};
103106

104-
async function handleClick() {
107+
async function findResources() {
105108
const prompt_version_id = searchParams?.get("prompt_version_id") ?? null;
106109

107110
setLoading(true);
108111
setResult(null);
109112
setErrorMessage(undefined);
110113
try {
111-
const request = clientDescription + getCollatedReferralOptions();
114+
const request = await buildRequestWithResolvedZipCodes();
115+
setRequestAfterZipResolution(request);
112116
const { resultId, resources, errorMessage } = await fetchResources(
113117
request,
114118
userEmail,
@@ -142,6 +146,7 @@ export default function Page() {
142146
setSelectedResources([]);
143147
setActionPlan(null);
144148
setErrorMessage(undefined);
149+
setRequestAfterZipResolution("");
145150
}
146151

147152
function handleResourceSelection(resource: Resource, checked: boolean) {
@@ -187,9 +192,49 @@ export default function Page() {
187192
}
188193
}
189194

190-
const getCollatedReferralOptions = (): string => {
191-
const resourceTypeFiltersPrefix =
192-
"\nInclude resources that support the following categories: ";
195+
const buildRequestWithResolvedZipCodes = async (): Promise<string> => {
196+
// Helper function to replace zip codes with "city, state zip_code" format
197+
const replaceZipCodes = async (text: string): Promise<string> => {
198+
if (!text) return text;
199+
200+
const zipCodeRegex = /\b\d{5}(?:-\d{4})?\b/g;
201+
const matches = Array.from(text.matchAll(zipCodeRegex));
202+
203+
if (matches.length === 0) return text;
204+
205+
// Collect unique zip codes to avoid duplicate API calls
206+
const uniqueZipCodes = Array.from(new Set(matches.map((m) => m[0])));
207+
208+
// Fetch locations for all unique zip codes
209+
const zipToLocation = new Map<string, string>();
210+
const locationPromises = uniqueZipCodes.map(async (zipCode) => {
211+
// For zip+4 format, only use the 5-digit part for lookup
212+
const zipForLookup = zipCode.split("-")[0];
213+
const location = await fetchLocationFromZip(zipForLookup);
214+
zipToLocation.set(zipCode, location);
215+
});
216+
await Promise.all(locationPromises);
217+
218+
// Replace all zip codes with their city, state prepended
219+
let result = text;
220+
for (const [zipCode, location] of zipToLocation.entries()) {
221+
if (location) {
222+
// Use a global replace to handle all occurrences of this zip code
223+
const zipRegex = new RegExp(
224+
`\\b${zipCode.replace(/-/g, "\\-")}\\b`,
225+
"g",
226+
);
227+
result = result.replace(zipRegex, `${location} ${zipCode}`);
228+
}
229+
}
230+
231+
return result;
232+
};
233+
234+
// Process both locationText and clientDescription for zip codes
235+
const processedLocationText = await replaceZipCodes(locationText);
236+
const processedClientDescription = await replaceZipCodes(clientDescription);
237+
193238
const resourceTypeFilters = selectedCategories
194239
.map((categoryId) => {
195240
const category = resourceCategories.find((c) => c.id === categoryId);
@@ -198,22 +243,21 @@ export default function Page() {
198243
.filter(Boolean)
199244
.join(", ");
200245

201-
const providerTypeFiltersPrefix =
202-
"\nInclude the following types of providers: ";
203-
const providerTypeFilters = selectedResourceTypes.join(", ");
204-
205-
const locationFilterPrefix =
206-
"\nFocus on resources close to the following location: ";
207-
208-
return (
246+
const options =
209247
(resourceTypeFilters.length > 0
210-
? resourceTypeFiltersPrefix + resourceTypeFilters
248+
? "\nInclude resources that support the following categories: " +
249+
resourceTypeFilters
211250
: "") +
212-
(providerTypeFilters
213-
? providerTypeFiltersPrefix + providerTypeFilters
251+
(selectedResourceTypes.length > 0
252+
? "\nInclude the following types of providers: " +
253+
selectedResourceTypes.join(", ")
214254
: "") +
215-
(locationText.length > 0 ? locationFilterPrefix + locationText : "")
216-
);
255+
(processedLocationText.length > 0
256+
? "\nFocus on resources close to the following location: " +
257+
processedLocationText
258+
: "");
259+
260+
return processedClientDescription + options;
217261
};
218262

219263
// Show nothing while checking localStorage to prevent flash
@@ -331,7 +375,7 @@ export default function Page() {
331375
onToggleResourceType={toggleResourceType}
332376
onLocationChange={setLocationText}
333377
onClientDescriptionChange={setClientDescription}
334-
onFindResources={() => void handleClick()}
378+
onFindResources={() => void findResources()}
335379
/>
336380
)}
337381
</TabsContent>
@@ -373,29 +417,36 @@ export default function Page() {
373417
{resultId && <EmailReferralsButton resultId={resultId} />}
374418
</div>
375419
</div>
376-
<ClientDetailsPromptBubble
377-
clientDescription={
378-
clientDescription + getCollatedReferralOptions()
379-
}
380-
/>
381-
<ResourcesList
382-
resources={retainedResources ?? []}
383-
errorMessage={errorMessage}
384-
handleRemoveResource={handleRemoveResource}
385-
/>
386-
{retainedResources && retainedResources.length > 0 && (
387-
<ActionPlanSection
388-
resources={retainedResources}
389-
selectedResources={selectedResources}
390-
actionPlan={actionPlan}
391-
isGeneratingActionPlan={isGeneratingActionPlan}
392-
onResourceSelection={handleResourceSelection}
393-
onSelectAllResources={handleSelectAllResources}
394-
onGenerateActionPlan={() => void generateActionPlan()}
395-
/>
396-
)}
397420
</div>
398421
)}
422+
423+
{requestAfterZipResolution && (
424+
<ClientDetailsPromptBubble
425+
clientDescription={requestAfterZipResolution}
426+
/>
427+
)}
428+
429+
{readyToPrint && (
430+
<ResourcesList
431+
resources={retainedResources ?? []}
432+
errorMessage={errorMessage}
433+
handleRemoveResource={handleRemoveResource}
434+
/>
435+
)}
436+
437+
{readyToPrint &&
438+
retainedResources &&
439+
retainedResources.length > 0 && (
440+
<ActionPlanSection
441+
resources={retainedResources}
442+
selectedResources={selectedResources}
443+
actionPlan={actionPlan}
444+
isGeneratingActionPlan={isGeneratingActionPlan}
445+
onResourceSelection={handleResourceSelection}
446+
onSelectAllResources={handleSelectAllResources}
447+
onGenerateActionPlan={() => void generateActionPlan()}
448+
/>
449+
)}
399450
</div>
400451
</div>
401452
)}

frontend/src/components/ClientDetailsInput.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ export function ClientDetailsInput({
146146
onClick={() => onToggleCategory(category.id)}
147147
data-testid={"resourceCategoryToggle-" + category.id}
148148
aria-pressed={isSelected}
149+
disabled={loading}
149150
>
150151
<Icon className="mr-2 w-6 h-6" />
151152
{category.label}
@@ -176,6 +177,7 @@ export function ClientDetailsInput({
176177
onClick={() => onToggleResourceType("goodwill")}
177178
data-testid={"resourceCategoryToggle-goodwill"}
178179
aria-pressed={selectedResourceTypes.includes("goodwill")}
180+
disabled={loading}
179181
>
180182
<Heart className="w-4 h-4 mr-2" />
181183
Goodwill Internal
@@ -195,6 +197,7 @@ export function ClientDetailsInput({
195197
onClick={() => onToggleResourceType("government")}
196198
data-testid={"resourceCategoryToggle-government"}
197199
aria-pressed={selectedResourceTypes.includes("government")}
200+
disabled={loading}
198201
>
199202
<Building className="w-4 h-4 mr-2" />
200203
Government
@@ -214,6 +217,7 @@ export function ClientDetailsInput({
214217
onClick={() => onToggleResourceType("community")}
215218
data-testid={"resourceCategoryToggle-community"}
216219
aria-pressed={selectedResourceTypes.includes("community")}
220+
disabled={loading}
217221
>
218222
<Users className="w-4 h-4 mr-2" />
219223
Community
@@ -234,6 +238,7 @@ export function ClientDetailsInput({
234238
onChange={(e) => onLocationChange(e.target.value)}
235239
className="border-gray-300 bg-white focus:ring-blue-500 focus:border-blue-500"
236240
data-testid="locationFilterInput"
241+
disabled={loading}
237242
/>
238243
</div>
239244
</div>
@@ -249,6 +254,7 @@ export function ClientDetailsInput({
249254
onClick={onClearAllFilters}
250255
className="text-gray-700 hover:bg-gray-100 hover:text-gray-900"
251256
data-testid="clearFiltersButton"
257+
disabled={loading}
252258
>
253259
Clear All Filters
254260
</Button>
@@ -270,6 +276,7 @@ export function ClientDetailsInput({
270276
onChange={(e) => onClientDescriptionChange(e.target.value)}
271277
className="min-h-[8rem] min-w-[16rem] text-base"
272278
data-testid="clientDescriptionInput"
279+
disabled={loading}
273280
/>
274281
</div>
275282

frontend/src/util/fetchLocation.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { LRUCache } from "lru-cache";
2+
3+
interface ZippopotamPlace {
4+
"place name": string; // city name
5+
state: string;
6+
"state abbreviation": string;
7+
longitude: string;
8+
latitude: string;
9+
}
10+
11+
interface ZippopotamResponse {
12+
places?: ZippopotamPlace[];
13+
}
14+
15+
// Cache for storing zip code lookup results (max 100 entries)
16+
const zipCodeCache = new LRUCache<string, string>({
17+
max: 100,
18+
});
19+
20+
export async function fetchLocationFromZip(zipCode: string): Promise<string> {
21+
// Check cache first
22+
const cachedValue = zipCodeCache.get(zipCode);
23+
if (cachedValue !== undefined) {
24+
return cachedValue;
25+
}
26+
27+
const ac = new AbortController();
28+
const timeoutId = setTimeout(() => ac.abort(), 5_000);
29+
30+
try {
31+
const response = await fetch(`https://api.zippopotam.us/us/${zipCode}`, {
32+
signal: ac.signal,
33+
});
34+
clearTimeout(timeoutId);
35+
36+
if (!response.ok) {
37+
// Cache empty string for failed lookups
38+
zipCodeCache.set(zipCode, "");
39+
return "";
40+
}
41+
42+
const data = (await response.json()) as ZippopotamResponse;
43+
const place = data.places?.[0];
44+
if (place) {
45+
const result = `${place["place name"]}, ${place["state abbreviation"]}`;
46+
// Cache successful result
47+
zipCodeCache.set(zipCode, result);
48+
return result;
49+
}
50+
} catch (error) {
51+
clearTimeout(timeoutId);
52+
if (error instanceof Error && error.name === "AbortError") {
53+
console.warn("Zip code lookup timed out:", zipCode);
54+
} else {
55+
console.warn("Error fetching city/state from zip code:", error);
56+
}
57+
}
58+
59+
// Cache empty string if no place found or errors (including timeouts)
60+
zipCodeCache.set(zipCode, "");
61+
return "";
62+
}

frontend/tests/generate-referrals/page.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ describe("Generate Referrals Page", () => {
258258
});
259259
});
260260

261-
describe("handleClick", () => {
261+
describe("findResources", () => {
262262
it("calls fetchResources with client description and filters", async () => {
263263
const user = userEvent.setup();
264264
const fetchResourcesSpy = jest

0 commit comments

Comments
 (0)