Skip to content

Commit df87d12

Browse files
authored
Refactor Socrata data fetching logic (#721)
* extract socrata fetch and query logic out of use-citations hook * implement runtime validation for parking citations response
1 parent 302ad39 commit df87d12

9 files changed

Lines changed: 173 additions & 85 deletions

File tree

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"react-map-gl": "^8.1.0",
3434
"use-sync-external-store": "^1.6.0",
3535
"wellknown": "^0.5.0",
36+
"zod": "^3.24.2",
3637
"zustand": "^5.0.9"
3738
},
3839
"devDependencies": {

apps/web/src/components/data-visuals.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Card, CardDescription, CardHeader, CardTitle } from "@lucky-parking/design/components";
22
import { useIsFetching } from "@tanstack/react-query";
33
import { useCitations } from "hooks/use-citations";
4-
import { ParkingCitationFeature } from "@/types";
4+
import { ParkingCitationFeature } from "@/lib/socrata/parking-citations.schema";
55

66
const calculateStatistics = (citations: ParkingCitationFeature[] = []) => {
77
const empty = { citations: { total: "--" }, fines: { total: "--", average: "--" } };

apps/web/src/components/map.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { MapLayerCircles, MAP_LAYER_CIRCLES_ID } from "@/components/map-layer-ci
88
import { MapLayerHeatmap } from "@/components/map-layer-heatmap";
99
import { useMapResizer } from "@/hooks/use-map-resizer";
1010
import { usePublicConfig } from "@/hooks/use-public-config";
11-
import { ParkingCitationFeature } from "@/types";
11+
import { ParkingCitationFeature } from "@/lib/socrata/parking-citations.schema";
1212
import { MapSourceCitations } from "./map-source-citations";
1313
import "mapbox-gl/dist/mapbox-gl.css";
1414

Lines changed: 7 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,23 @@
11
import { useQuery } from "@tanstack/react-query";
2-
import { multiPolygon } from "@turf/turf";
32
import _ from "lodash";
4-
import { type GeoJSONGeometry, stringify } from "wellknown";
53
import { usePublicConfig } from "@/hooks/use-public-config";
6-
import { ParkingCitationFeatureCollection, ParkingCitationProperties } from "@/types";
4+
import { fetchParkingCitations } from "@/lib/socrata/parking-citations";
75
import { useStore } from "./use-store";
86

9-
const GEO_LOCATION_COLUMN = "geocodelocation";
10-
const ISSUE_DATE_COLUMN = "issue_date";
11-
12-
const EMPTY_PARKING_CITATION_FEATURE_COLLECTION = {
13-
type: "FeatureCollection",
14-
features: [] satisfies ParkingCitationProperties[],
15-
} satisfies ParkingCitationFeatureCollection;
16-
177
export const useCitations = () => {
188
const { data } = usePublicConfig();
199
const { places, range } = useStore((state) => ({ places: state.getPlaces(), range: state.range }));
2010

2111
const placeIds = _.chain(places).map("id").compact().uniq().sort().value();
2212

23-
const buildQuery = () => {
24-
const startDate = range.from.toISOString().split("T")[0];
25-
const endDate = range.to.toISOString().split("T")[0];
26-
const dateFilter = `${ISSUE_DATE_COLUMN} BETWEEN '${startDate}' AND '${endDate}'`;
27-
28-
if (!placeIds.length) return `SELECT * WHERE ${dateFilter}`;
29-
30-
const coordinates = _.chain(places).map("geometry.coordinates").compact().value();
31-
const multipolygon = multiPolygon(coordinates);
32-
const wkt = stringify(multipolygon.geometry as GeoJSONGeometry);
33-
const geoFilter = `within_polygon(${GEO_LOCATION_COLUMN}, '${wkt}')`;
34-
return `SELECT * WHERE ${dateFilter} AND ${geoFilter}`;
35-
};
36-
3713
return useQuery({
3814
queryKey: ["citations", range.from.toISOString(), range.to.toISOString(), placeIds],
39-
queryFn: async (): Promise<ParkingCitationFeatureCollection> => {
40-
if (!(placeIds.length && data?.socrataAppToken)) return EMPTY_PARKING_CITATION_FEATURE_COLLECTION;
41-
42-
const response = await fetch("https://data.lacity.org/api/v3/views/4f5p-udkv/query", {
43-
method: "POST",
44-
headers: {
45-
Accept: "application/vnd.geo+json",
46-
"Accept-Charset": "utf-8",
47-
"Content-Type": "application/json",
48-
"X-App-Token": data.socrataAppToken,
49-
},
50-
// FIXME: Remove limit (pagination) when implementing full data fetching
51-
body: JSON.stringify({ query: buildQuery(), page: { pageNumber: 1, pageSize: 50 } }),
52-
});
53-
54-
return response.ok ? response.json() : EMPTY_PARKING_CITATION_FEATURE_COLLECTION;
55-
},
15+
queryFn: () =>
16+
fetchParkingCitations({
17+
token: data?.socrataAppToken,
18+
places,
19+
range,
20+
}),
5621
staleTime: 60_000, // 1 minute
5722
});
5823
};
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { z } from "zod";
2+
3+
/* ——————————————— Schemas ——————————————— */
4+
5+
export const PointGeometrySchema = z.object({
6+
type: z.literal("Point"),
7+
coordinates: z.tuple([z.number(), z.number()]),
8+
});
9+
10+
export const FeatureSchema = <T extends z.ZodTypeAny>(properties: T) =>
11+
z.object({
12+
type: z.literal("Feature"),
13+
geometry: PointGeometrySchema,
14+
properties,
15+
});
16+
17+
export const FeatureCollectionSchema = <T extends z.ZodTypeAny>(featureSchema: T) =>
18+
z.object({
19+
type: z.literal("FeatureCollection"),
20+
features: z.array(featureSchema),
21+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Feature, FeatureCollection, Point } from "geojson";
2+
import { z } from "zod";
3+
import { FeatureSchema, FeatureCollectionSchema } from "@/lib/geojson/geojson.schema";
4+
5+
/* ——————————————— Schemas ——————————————— */
6+
7+
export const ParkingCitationPropertiesSchema = z.object({
8+
ticket_number: z.string(),
9+
issue_date: z.string(), // ISO-like datetime string
10+
issue_time: z.string(), // HHMM as string (e.g. "846")
11+
meter_id: z.string().nullable(),
12+
marked_time: z.string(),
13+
rp_state_plate: z.string(),
14+
plate_expiry_date: z.string(), // YYYYMM
15+
vin: z.string().nullable(),
16+
make: z.string(),
17+
body_style: z.string(),
18+
color: z.string(),
19+
location: z.string(),
20+
route: z.string().nullable(),
21+
agency: z.string(),
22+
violation_code: z.string(),
23+
violation_description: z.string(),
24+
fine_amount: z.string(),
25+
agency_desc: z.string(),
26+
color_desc: z.string(),
27+
body_style_desc: z.string(),
28+
loc_lat: z.string(),
29+
loc_long: z.string(),
30+
});
31+
32+
export const ParkingCitationFeatureSchema = FeatureSchema(ParkingCitationPropertiesSchema);
33+
34+
export const ParkingCitationFeatureCollectionSchema = FeatureCollectionSchema(ParkingCitationFeatureSchema);
35+
36+
/* ——————————————— Types ——————————————— */
37+
38+
export type ParkingCitationProperties = z.infer<typeof ParkingCitationPropertiesSchema>;
39+
40+
export type ParkingCitationFeature = Feature<Point, ParkingCitationProperties>;
41+
42+
export type ParkingCitationFeatureCollection = FeatureCollection<Point, ParkingCitationProperties>;
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { multiPolygon } from "@turf/turf";
2+
import _ from "lodash";
3+
import { type GeoJSONGeometry, stringify } from "wellknown";
4+
import { ParkingCitationFeatureCollection, ParkingCitationFeatureCollectionSchema } from "./parking-citations.schema";
5+
6+
type FetchParkingCitationsInput = {
7+
token?: string;
8+
places: Array<{
9+
id?: string | number | null;
10+
geometry?: {
11+
coordinates?: unknown;
12+
} | null;
13+
}>;
14+
range: {
15+
from: Date;
16+
to: Date;
17+
};
18+
};
19+
20+
const SOCRATA_PARKING_CITATIONS_URL = "https://data.lacity.org/api/v3/views/4f5p-udkv/query";
21+
22+
const GEO_LOCATION_COLUMN = "geocodelocation";
23+
const ISSUE_DATE_COLUMN = "issue_date";
24+
25+
const EMPTY_PARKING_CITATION_FEATURE_COLLECTION = {
26+
type: "FeatureCollection",
27+
features: [],
28+
} satisfies ParkingCitationFeatureCollection;
29+
30+
const toSocrataDate = (date: Date) => date.toISOString().split("T")[0];
31+
32+
const buildParkingCitationsQuery = ({ places, range }: Pick<FetchParkingCitationsInput, "places" | "range">) => {
33+
const startDate = toSocrataDate(range.from);
34+
const endDate = toSocrataDate(range.to);
35+
const dateFilter = `${ISSUE_DATE_COLUMN} BETWEEN '${startDate}' AND '${endDate}'`;
36+
37+
const placeIds = _.chain(places).map("id").compact().uniq().sort().value();
38+
39+
if (!placeIds.length) {
40+
return `SELECT * WHERE ${dateFilter}`;
41+
}
42+
43+
const coordinates = _.chain(places).map("geometry.coordinates").compact().value();
44+
45+
const multipolygon = multiPolygon(coordinates);
46+
const wkt = stringify(multipolygon.geometry as GeoJSONGeometry);
47+
const geoFilter = `within_polygon(${GEO_LOCATION_COLUMN}, '${wkt}')`;
48+
49+
return `SELECT * WHERE ${dateFilter} AND ${geoFilter}`;
50+
};
51+
52+
export const fetchParkingCitations = async ({
53+
token,
54+
places,
55+
range,
56+
}: FetchParkingCitationsInput): Promise<ParkingCitationFeatureCollection> => {
57+
const placeIds = _.chain(places).map("id").compact().uniq().sort().value();
58+
59+
if (!(placeIds.length && token)) {
60+
return EMPTY_PARKING_CITATION_FEATURE_COLLECTION;
61+
}
62+
63+
const response = await fetch(SOCRATA_PARKING_CITATIONS_URL, {
64+
method: "POST",
65+
headers: {
66+
Accept: "application/vnd.geo+json",
67+
"Accept-Charset": "utf-8",
68+
"Content-Type": "application/json",
69+
"X-App-Token": token,
70+
},
71+
body: JSON.stringify({
72+
query: buildParkingCitationsQuery({ places, range }),
73+
// FIXME: Remove limit (pagination) when implementing full data fetching
74+
page: {
75+
pageNumber: 1,
76+
pageSize: 50,
77+
},
78+
}),
79+
});
80+
81+
if (!response.ok) {
82+
return EMPTY_PARKING_CITATION_FEATURE_COLLECTION;
83+
}
84+
85+
const json: unknown = await response.json();
86+
const parsed = ParkingCitationFeatureCollectionSchema.safeParse(json);
87+
88+
if (!parsed.success) {
89+
console.error("Invalid parking citations response", parsed.error);
90+
return EMPTY_PARKING_CITATION_FEATURE_COLLECTION;
91+
}
92+
93+
return parsed.data;
94+
};

apps/web/src/types.ts

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -13,44 +13,6 @@ export type AnyRef<T> = RefObject<T | null>;
1313
*/
1414
export type NonNullableProperties<T> = { [P in keyof T]: NonNullable<T[P]> };
1515

16-
/**
17-
* Properties for a parking citation feature
18-
*/
19-
export type ParkingCitationProperties = {
20-
ticket_number: string;
21-
issue_date: string; // ISO-like datetime string
22-
issue_time: string; // HHMM as string (e.g. "846")
23-
meter_id: string | null;
24-
marked_time: string;
25-
rp_state_plate: string;
26-
plate_expiry_date: string; // YYYYMM
27-
vin: string | null;
28-
make: string;
29-
body_style: string;
30-
color: string;
31-
location: string;
32-
route: string | null;
33-
agency: string;
34-
violation_code: string;
35-
violation_description: string;
36-
fine_amount: string; // stored as string in source data
37-
agency_desc: string;
38-
color_desc: string;
39-
body_style_desc: string;
40-
loc_lat: string;
41-
loc_long: string;
42-
};
43-
44-
/**
45-
* A single parking citation feature
46-
*/
47-
export type ParkingCitationFeature = Feature<Point, ParkingCitationProperties>;
48-
49-
/**
50-
* Collection of parking citation features
51-
*/
52-
export type ParkingCitationFeatureCollection = FeatureCollection<Point, ParkingCitationProperties>;
53-
5416
export type NeighborhoodCouncilProperties = (typeof neighborhoodCouncilCollection)["features"][0]["properties"];
5517

5618
export type NeighborhoodCouncilFeature = Feature<Polygon, NeighborhoodCouncilProperties>;

pnpm-lock.yaml

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

0 commit comments

Comments
 (0)