From 2f859bf6fdf818c83d7aeadd098b6594bd3e3c43 Mon Sep 17 00:00:00 2001 From: Glen Paul Florendo <9373317+glenflorendo@users.noreply.github.com> Date: Tue, 5 May 2026 12:53:30 -0700 Subject: [PATCH 1/2] extract socrata fetch and query logic out of use-citations hook --- apps/web/src/hooks/use-citations.tsx | 49 ++--------- apps/web/src/lib/socrata/parking-citations.ts | 86 +++++++++++++++++++ 2 files changed, 93 insertions(+), 42 deletions(-) create mode 100644 apps/web/src/lib/socrata/parking-citations.ts diff --git a/apps/web/src/hooks/use-citations.tsx b/apps/web/src/hooks/use-citations.tsx index 11051bf9..a678ce36 100644 --- a/apps/web/src/hooks/use-citations.tsx +++ b/apps/web/src/hooks/use-citations.tsx @@ -1,58 +1,23 @@ import { useQuery } from "@tanstack/react-query"; -import { multiPolygon } from "@turf/turf"; import _ from "lodash"; -import { type GeoJSONGeometry, stringify } from "wellknown"; import { usePublicConfig } from "@/hooks/use-public-config"; -import { ParkingCitationFeatureCollection, ParkingCitationProperties } from "@/types"; +import { fetchParkingCitations } from "@/lib/socrata/parking-citations"; import { useStore } from "./use-store"; -const GEO_LOCATION_COLUMN = "geocodelocation"; -const ISSUE_DATE_COLUMN = "issue_date"; - -const EMPTY_PARKING_CITATION_FEATURE_COLLECTION = { - type: "FeatureCollection", - features: [] satisfies ParkingCitationProperties[], -} satisfies ParkingCitationFeatureCollection; - export const useCitations = () => { const { data } = usePublicConfig(); const { places, range } = useStore((state) => ({ places: state.getPlaces(), range: state.range })); const placeIds = _.chain(places).map("id").compact().uniq().sort().value(); - const buildQuery = () => { - const startDate = range.from.toISOString().split("T")[0]; - const endDate = range.to.toISOString().split("T")[0]; - const dateFilter = `${ISSUE_DATE_COLUMN} BETWEEN '${startDate}' AND '${endDate}'`; - - if (!placeIds.length) return `SELECT * WHERE ${dateFilter}`; - - const coordinates = _.chain(places).map("geometry.coordinates").compact().value(); - const multipolygon = multiPolygon(coordinates); - const wkt = stringify(multipolygon.geometry as GeoJSONGeometry); - const geoFilter = `within_polygon(${GEO_LOCATION_COLUMN}, '${wkt}')`; - return `SELECT * WHERE ${dateFilter} AND ${geoFilter}`; - }; - return useQuery({ queryKey: ["citations", range.from.toISOString(), range.to.toISOString(), placeIds], - queryFn: async (): Promise => { - if (!(placeIds.length && data?.socrataAppToken)) return EMPTY_PARKING_CITATION_FEATURE_COLLECTION; - - const response = await fetch("https://data.lacity.org/api/v3/views/4f5p-udkv/query", { - method: "POST", - headers: { - Accept: "application/vnd.geo+json", - "Accept-Charset": "utf-8", - "Content-Type": "application/json", - "X-App-Token": data.socrataAppToken, - }, - // FIXME: Remove limit (pagination) when implementing full data fetching - body: JSON.stringify({ query: buildQuery(), page: { pageNumber: 1, pageSize: 50 } }), - }); - - return response.ok ? response.json() : EMPTY_PARKING_CITATION_FEATURE_COLLECTION; - }, + queryFn: () => + fetchParkingCitations({ + token: data?.socrataAppToken, + places, + range, + }), staleTime: 60_000, // 1 minute }); }; diff --git a/apps/web/src/lib/socrata/parking-citations.ts b/apps/web/src/lib/socrata/parking-citations.ts new file mode 100644 index 00000000..6730f626 --- /dev/null +++ b/apps/web/src/lib/socrata/parking-citations.ts @@ -0,0 +1,86 @@ +import { multiPolygon } from "@turf/turf"; +import _ from "lodash"; +import { type GeoJSONGeometry, stringify } from "wellknown"; +import { ParkingCitationFeatureCollection, ParkingCitationProperties } from "@/types"; + +type FetchParkingCitationsInput = { + token?: string; + places: Array<{ + id?: string | number | null; + geometry?: { + coordinates?: unknown; + } | null; + }>; + range: { + from: Date; + to: Date; + }; +}; + +const SOCRATA_PARKING_CITATIONS_URL = "https://data.lacity.org/api/v3/views/4f5p-udkv/query"; + +const GEO_LOCATION_COLUMN = "geocodelocation"; +const ISSUE_DATE_COLUMN = "issue_date"; + +const EMPTY_PARKING_CITATION_FEATURE_COLLECTION = { + type: "FeatureCollection", + features: [] satisfies ParkingCitationProperties[], +} satisfies ParkingCitationFeatureCollection; + +const toSocrataDate = (date: Date) => date.toISOString().split("T")[0]; + +const buildParkingCitationsQuery = ({ places, range }: Pick) => { + const startDate = toSocrataDate(range.from); + const endDate = toSocrataDate(range.to); + const dateFilter = `${ISSUE_DATE_COLUMN} BETWEEN '${startDate}' AND '${endDate}'`; + + const placeIds = _.chain(places).map("id").compact().uniq().sort().value(); + + if (!placeIds.length) { + return `SELECT * WHERE ${dateFilter}`; + } + + const coordinates = _.chain(places).map("geometry.coordinates").compact().value(); + + const multipolygon = multiPolygon(coordinates); + const wkt = stringify(multipolygon.geometry as GeoJSONGeometry); + const geoFilter = `within_polygon(${GEO_LOCATION_COLUMN}, '${wkt}')`; + + return `SELECT * WHERE ${dateFilter} AND ${geoFilter}`; +}; + +export const fetchParkingCitations = async ({ + token, + places, + range, +}: FetchParkingCitationsInput): Promise => { + const placeIds = _.chain(places).map("id").compact().uniq().sort().value(); + + if (!(placeIds.length && token)) { + return EMPTY_PARKING_CITATION_FEATURE_COLLECTION; + } + + const response = await fetch(SOCRATA_PARKING_CITATIONS_URL, { + method: "POST", + headers: { + Accept: "application/vnd.geo+json", + "Accept-Charset": "utf-8", + "Content-Type": "application/json", + "X-App-Token": token, + }, + body: JSON.stringify({ + query: buildParkingCitationsQuery({ places, range }), + // FIXME: Remove limit (pagination) when implementing full data fetching + page: { + pageNumber: 1, + pageSize: 50, + }, + }), + }); + + if (!response.ok) { + return EMPTY_PARKING_CITATION_FEATURE_COLLECTION; + } + + return response.json(); +}; From 3d556c02c4f39e91cd2ce0ba866c7e8523fdef5d Mon Sep 17 00:00:00 2001 From: Glen Paul Florendo <9373317+glenflorendo@users.noreply.github.com> Date: Tue, 5 May 2026 13:40:46 -0700 Subject: [PATCH 2/2] implement runtime validation for parking citations response --- apps/web/package.json | 1 + apps/web/src/components/data-visuals.tsx | 2 +- apps/web/src/components/map.tsx | 2 +- apps/web/src/lib/geojson/geojson.schema.ts | 21 ++++++++++ .../lib/socrata/parking-citations.schema.ts | 42 +++++++++++++++++++ apps/web/src/lib/socrata/parking-citations.ts | 14 +++++-- apps/web/src/types.ts | 38 ----------------- pnpm-lock.yaml | 9 ++-- 8 files changed, 83 insertions(+), 46 deletions(-) create mode 100644 apps/web/src/lib/geojson/geojson.schema.ts create mode 100644 apps/web/src/lib/socrata/parking-citations.schema.ts diff --git a/apps/web/package.json b/apps/web/package.json index 844f90be..75f792d0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -33,6 +33,7 @@ "react-map-gl": "^8.1.0", "use-sync-external-store": "^1.6.0", "wellknown": "^0.5.0", + "zod": "^3.24.2", "zustand": "^5.0.9" }, "devDependencies": { diff --git a/apps/web/src/components/data-visuals.tsx b/apps/web/src/components/data-visuals.tsx index b8c9617c..5b6a4c01 100644 --- a/apps/web/src/components/data-visuals.tsx +++ b/apps/web/src/components/data-visuals.tsx @@ -1,7 +1,7 @@ import { Card, CardDescription, CardHeader, CardTitle } from "@lucky-parking/design/components"; import { useIsFetching } from "@tanstack/react-query"; import { useCitations } from "hooks/use-citations"; -import { ParkingCitationFeature } from "@/types"; +import { ParkingCitationFeature } from "@/lib/socrata/parking-citations.schema"; const calculateStatistics = (citations: ParkingCitationFeature[] = []) => { const empty = { citations: { total: "--" }, fines: { total: "--", average: "--" } }; diff --git a/apps/web/src/components/map.tsx b/apps/web/src/components/map.tsx index cb1972f3..4c32af66 100644 --- a/apps/web/src/components/map.tsx +++ b/apps/web/src/components/map.tsx @@ -8,7 +8,7 @@ import { MapLayerCircles, MAP_LAYER_CIRCLES_ID } from "@/components/map-layer-ci import { MapLayerHeatmap } from "@/components/map-layer-heatmap"; import { useMapResizer } from "@/hooks/use-map-resizer"; import { usePublicConfig } from "@/hooks/use-public-config"; -import { ParkingCitationFeature } from "@/types"; +import { ParkingCitationFeature } from "@/lib/socrata/parking-citations.schema"; import { MapSourceCitations } from "./map-source-citations"; import "mapbox-gl/dist/mapbox-gl.css"; diff --git a/apps/web/src/lib/geojson/geojson.schema.ts b/apps/web/src/lib/geojson/geojson.schema.ts new file mode 100644 index 00000000..c88301ae --- /dev/null +++ b/apps/web/src/lib/geojson/geojson.schema.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +/* ——————————————— Schemas ——————————————— */ + +export const PointGeometrySchema = z.object({ + type: z.literal("Point"), + coordinates: z.tuple([z.number(), z.number()]), +}); + +export const FeatureSchema = (properties: T) => + z.object({ + type: z.literal("Feature"), + geometry: PointGeometrySchema, + properties, + }); + +export const FeatureCollectionSchema = (featureSchema: T) => + z.object({ + type: z.literal("FeatureCollection"), + features: z.array(featureSchema), + }); diff --git a/apps/web/src/lib/socrata/parking-citations.schema.ts b/apps/web/src/lib/socrata/parking-citations.schema.ts new file mode 100644 index 00000000..a5fce2cb --- /dev/null +++ b/apps/web/src/lib/socrata/parking-citations.schema.ts @@ -0,0 +1,42 @@ +import { Feature, FeatureCollection, Point } from "geojson"; +import { z } from "zod"; +import { FeatureSchema, FeatureCollectionSchema } from "@/lib/geojson/geojson.schema"; + +/* ——————————————— Schemas ——————————————— */ + +export const ParkingCitationPropertiesSchema = z.object({ + ticket_number: z.string(), + issue_date: z.string(), // ISO-like datetime string + issue_time: z.string(), // HHMM as string (e.g. "846") + meter_id: z.string().nullable(), + marked_time: z.string(), + rp_state_plate: z.string(), + plate_expiry_date: z.string(), // YYYYMM + vin: z.string().nullable(), + make: z.string(), + body_style: z.string(), + color: z.string(), + location: z.string(), + route: z.string().nullable(), + agency: z.string(), + violation_code: z.string(), + violation_description: z.string(), + fine_amount: z.string(), + agency_desc: z.string(), + color_desc: z.string(), + body_style_desc: z.string(), + loc_lat: z.string(), + loc_long: z.string(), +}); + +export const ParkingCitationFeatureSchema = FeatureSchema(ParkingCitationPropertiesSchema); + +export const ParkingCitationFeatureCollectionSchema = FeatureCollectionSchema(ParkingCitationFeatureSchema); + +/* ——————————————— Types ——————————————— */ + +export type ParkingCitationProperties = z.infer; + +export type ParkingCitationFeature = Feature; + +export type ParkingCitationFeatureCollection = FeatureCollection; diff --git a/apps/web/src/lib/socrata/parking-citations.ts b/apps/web/src/lib/socrata/parking-citations.ts index 6730f626..4aed1b59 100644 --- a/apps/web/src/lib/socrata/parking-citations.ts +++ b/apps/web/src/lib/socrata/parking-citations.ts @@ -1,7 +1,7 @@ import { multiPolygon } from "@turf/turf"; import _ from "lodash"; import { type GeoJSONGeometry, stringify } from "wellknown"; -import { ParkingCitationFeatureCollection, ParkingCitationProperties } from "@/types"; +import { ParkingCitationFeatureCollection, ParkingCitationFeatureCollectionSchema } from "./parking-citations.schema"; type FetchParkingCitationsInput = { token?: string; @@ -24,7 +24,7 @@ const ISSUE_DATE_COLUMN = "issue_date"; const EMPTY_PARKING_CITATION_FEATURE_COLLECTION = { type: "FeatureCollection", - features: [] satisfies ParkingCitationProperties[], + features: [], } satisfies ParkingCitationFeatureCollection; const toSocrataDate = (date: Date) => date.toISOString().split("T")[0]; @@ -82,5 +82,13 @@ export const fetchParkingCitations = async ({ return EMPTY_PARKING_CITATION_FEATURE_COLLECTION; } - return response.json(); + const json: unknown = await response.json(); + const parsed = ParkingCitationFeatureCollectionSchema.safeParse(json); + + if (!parsed.success) { + console.error("Invalid parking citations response", parsed.error); + return EMPTY_PARKING_CITATION_FEATURE_COLLECTION; + } + + return parsed.data; }; diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 32b73a76..b81bc3b8 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -13,44 +13,6 @@ export type AnyRef = RefObject; */ export type NonNullableProperties = { [P in keyof T]: NonNullable }; -/** - * Properties for a parking citation feature - */ -export type ParkingCitationProperties = { - ticket_number: string; - issue_date: string; // ISO-like datetime string - issue_time: string; // HHMM as string (e.g. "846") - meter_id: string | null; - marked_time: string; - rp_state_plate: string; - plate_expiry_date: string; // YYYYMM - vin: string | null; - make: string; - body_style: string; - color: string; - location: string; - route: string | null; - agency: string; - violation_code: string; - violation_description: string; - fine_amount: string; // stored as string in source data - agency_desc: string; - color_desc: string; - body_style_desc: string; - loc_lat: string; - loc_long: string; -}; - -/** - * A single parking citation feature - */ -export type ParkingCitationFeature = Feature; - -/** - * Collection of parking citation features - */ -export type ParkingCitationFeatureCollection = FeatureCollection; - export type NeighborhoodCouncilProperties = (typeof neighborhoodCouncilCollection)["features"][0]["properties"]; export type NeighborhoodCouncilFeature = Feature; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 173e6a2a..1d7403eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,7 +71,7 @@ importers: version: 1.9.9 tsup: specifier: ^8.4.0 - version: 8.4.0(jiti@2.6.1)(postcss@8.5.3)(tsx@4.19.3)(typescript@5.9.3)(yaml@2.8.2) + version: 8.4.0(jiti@2.6.1)(postcss@8.5.3)(tsx@4.19.3)(typescript@5.8.2)(yaml@2.8.2) tsx: specifier: ^4.19.3 version: 4.19.3 @@ -135,6 +135,9 @@ importers: wellknown: specifier: ^0.5.0 version: 0.5.0 + zod: + specifier: ^3.24.2 + version: 3.25.76 zustand: specifier: ^5.0.9 version: 5.0.10(@types/react@19.1.2)(immer@11.1.3)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)) @@ -10368,7 +10371,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.4.0(jiti@2.6.1)(postcss@8.5.3)(tsx@4.19.3)(typescript@5.9.3)(yaml@2.8.2): + tsup@8.4.0(jiti@2.6.1)(postcss@8.5.3)(tsx@4.19.3)(typescript@5.8.2)(yaml@2.8.2): dependencies: bundle-require: 5.1.0(esbuild@0.25.2) cac: 6.7.14 @@ -10388,7 +10391,7 @@ snapshots: tree-kill: 1.2.2 optionalDependencies: postcss: 8.5.3 - typescript: 5.9.3 + typescript: 5.8.2 transitivePeerDependencies: - jiti - supports-color