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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/data-visuals.tsx
Original file line number Diff line number Diff line change
@@ -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: "--" } };
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
49 changes: 7 additions & 42 deletions apps/web/src/hooks/use-citations.tsx
Original file line number Diff line number Diff line change
@@ -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<ParkingCitationFeatureCollection> => {
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
});
};
21 changes: 21 additions & 0 deletions apps/web/src/lib/geojson/geojson.schema.ts
Original file line number Diff line number Diff line change
@@ -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 = <T extends z.ZodTypeAny>(properties: T) =>
z.object({
type: z.literal("Feature"),
geometry: PointGeometrySchema,
properties,
});

export const FeatureCollectionSchema = <T extends z.ZodTypeAny>(featureSchema: T) =>
z.object({
type: z.literal("FeatureCollection"),
features: z.array(featureSchema),
});
42 changes: 42 additions & 0 deletions apps/web/src/lib/socrata/parking-citations.schema.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ParkingCitationPropertiesSchema>;

export type ParkingCitationFeature = Feature<Point, ParkingCitationProperties>;

export type ParkingCitationFeatureCollection = FeatureCollection<Point, ParkingCitationProperties>;
94 changes: 94 additions & 0 deletions apps/web/src/lib/socrata/parking-citations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { multiPolygon } from "@turf/turf";
import _ from "lodash";
import { type GeoJSONGeometry, stringify } from "wellknown";
import { ParkingCitationFeatureCollection, ParkingCitationFeatureCollectionSchema } from "./parking-citations.schema";

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 ParkingCitationFeatureCollection;

const toSocrataDate = (date: Date) => date.toISOString().split("T")[0];

const buildParkingCitationsQuery = ({ places, range }: Pick<FetchParkingCitationsInput, "places" | "range">) => {
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<ParkingCitationFeatureCollection> => {
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;
}

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;
};
38 changes: 0 additions & 38 deletions apps/web/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,44 +13,6 @@ export type AnyRef<T> = RefObject<T | null>;
*/
export type NonNullableProperties<T> = { [P in keyof T]: NonNullable<T[P]> };

/**
* 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<Point, ParkingCitationProperties>;

/**
* Collection of parking citation features
*/
export type ParkingCitationFeatureCollection = FeatureCollection<Point, ParkingCitationProperties>;

export type NeighborhoodCouncilProperties = (typeof neighborhoodCouncilCollection)["features"][0]["properties"];

export type NeighborhoodCouncilFeature = Feature<Polygon, NeighborhoodCouncilProperties>;
Expand Down
9 changes: 6 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading