From 37098cb6eb3dd12e98e3e731ec99b83bcc4a5f08 Mon Sep 17 00:00:00 2001 From: puneet khanna Date: Thu, 25 Jun 2026 12:46:39 -0700 Subject: [PATCH 1/2] Add complaints-over-time endpoint and complaint count overview chart --- backend/routes/complaints.py | 29 ++++++++++++ backend/tests/test_complaints.py | 24 ++++++++++ frontend/app/overview/page.tsx | 69 ++++++++++++++++++++++++++++ frontend/app/page.tsx | 2 + frontend/components/Nav/nav.test.tsx | 1 + frontend/components/Nav/nav.tsx | 1 + frontend/utils/api.ts | 5 ++ frontend/utils/apiRoutes.ts | 3 ++ 8 files changed, 134 insertions(+) create mode 100644 frontend/app/overview/page.tsx diff --git a/backend/routes/complaints.py b/backend/routes/complaints.py index b2f196a06..cc3ff064f 100644 --- a/backend/routes/complaints.py +++ b/backend/routes/complaints.py @@ -1,5 +1,6 @@ import logging from functools import wraps +from neomodel import db from backend.auth.jwt import min_role_required from backend.mixpanel.mix import track_to_mp @@ -450,6 +451,34 @@ def get_all_complaints(): return ordered_jsonify(results), 200 +# Get complaint counts grouped by year +@bp.route("/metrics/over-time", methods=["GET"]) +@jwt_required() +@min_role_required(UserRole.PUBLIC) +def get_complaints_over_time(): + """ + Powers the Complaints Over Time line graph on the Overview page. + """ + query = """ + MATCH (c:Complaint) + WHERE c.incident_date IS NOT NULL + RETURN substring(toString(c.incident_date), 0, 4) AS year, + count(c) AS complaint_count + ORDER BY year + """ + try: + results, _ = db.cypher_query(query) + except Exception as e: + logging.error(f"Error fetching complaints over time: {e}") + abort(500, description="Failed to fetch complaint trends.") + + data = [ + {"year": row[0], "complaint_count": row[1]} + for row in results + ] + return ordered_jsonify(data), 200 + + # Update a complaint record @bp.route("/", methods=["PATCH"]) @jwt_required() diff --git a/backend/tests/test_complaints.py b/backend/tests/test_complaints.py index c1369bdb9..450c9aaa8 100644 --- a/backend/tests/test_complaints.py +++ b/backend/tests/test_complaints.py @@ -942,3 +942,27 @@ def test_create_complaint_with_multiple_allegations( expected_civ_ids = {f"{complaint.uid}-1", f"{complaint.uid}-2", f"{complaint.uid}-3"} assert civ_ids_seen == expected_civ_ids + + +def test_get_complaints_over_time( + client, db_session, access_token, example_complaints): + """Test the complaints-over-time metrics endpoint. + The example_complaints fixture seeds 3 complaints, all with an + incident_date in 2022, so the result should contain a single + year entry of "2022" with a count of 3. + """ + res = client.get( + "/api/v1/complaints/metrics/over-time", + headers={"Authorization": "Bearer {0}".format(access_token)}, + ) + + assert res.status_code == 200 + + # Result is a list of {year, complaint_count} objects + data = res.json + assert isinstance(data, list) + + # All seeded complaints are from 2022 + year_2022 = [row for row in data if row["year"] == "2022"] + assert len(year_2022) == 1 + assert year_2022[0]["complaint_count"] == 3 diff --git a/frontend/app/overview/page.tsx b/frontend/app/overview/page.tsx new file mode 100644 index 000000000..dc83d7933 --- /dev/null +++ b/frontend/app/overview/page.tsx @@ -0,0 +1,69 @@ +"use client" + +import { useEffect, useState } from "react" +import { LineChart } from "@mui/x-charts/LineChart" +import { apiFetch } from "@/utils/apiFetch" +import { apiBaseUrl } from "@/utils/apiRoutes" +import API_ROUTES from "@/utils/apiRoutes" +import { ComplaintsOverTimePoint } from "@/utils/api" + +export default function OverviewPage() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let active = true + + const loadData = async (): Promise => { + try { + const res = await apiFetch(`${apiBaseUrl}${API_ROUTES.complaints.metricsOverTime}`) + if (!res.ok) { + throw new Error(`Request failed with status ${res.status}`) + } + const json: ComplaintsOverTimePoint[] = await res.json() + if (active) { + setData(json) + } + } catch (err) { + if (active) { + setError(err instanceof Error ? err.message : "Failed to load data") + } + } + } + + loadData() + return () => { + active = false + } + }, []) + + if (error) { + return
Error: {error}
+ } + + if (data === null) { + return
Loading…
+ } + + return ( +
+

Complaints Over Time

+ point.year), + scaleType: "point", + label: "Year" + } + ]} + series={[ + { + data: data.map((point) => point.complaint_count), + label: "Complaints" + } + ]} + height={400} + /> +
+ ) +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 7341dd436..410da061b 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,6 +1,7 @@ "use client" import styles from "./page.module.css" import SearchBar from "@/components/SearchBar" +import Link from "next/link" export default function Home() { return ( @@ -8,6 +9,7 @@ export default function Home() {

How can we help you?

+ Click for an overview of complaint counts
) diff --git a/frontend/components/Nav/nav.test.tsx b/frontend/components/Nav/nav.test.tsx index e09e5f232..48f7be607 100644 --- a/frontend/components/Nav/nav.test.tsx +++ b/frontend/components/Nav/nav.test.tsx @@ -46,6 +46,7 @@ test("Nav renders the Feedback link", () => { test("Nav renders navigation links", () => { const { unmount } = customRender() expect(screen.getByText("Home")) + expect(screen.getByText("Overview")) expect(screen.getByText("Data Explorer")) expect(screen.getByText("Community")) expect(screen.getByText("Collection")) diff --git a/frontend/components/Nav/nav.tsx b/frontend/components/Nav/nav.tsx index 5dd4dfcc2..3ecf962c9 100644 --- a/frontend/components/Nav/nav.tsx +++ b/frontend/components/Nav/nav.tsx @@ -11,6 +11,7 @@ import styles from "./nav.module.css" export default function Nav() { const Links = [ { text: "Home", href: "/" }, + { text: "Overview", href: "/overview" }, { text: "Data Explorer", href: "/data-explorer", disabled: true }, { text: "Community", href: "/community", disabled: true }, { text: "Collection", href: "/collection", disabled: true } diff --git a/frontend/utils/api.ts b/frontend/utils/api.ts index 0e5722923..841f0c670 100644 --- a/frontend/utils/api.ts +++ b/frontend/utils/api.ts @@ -370,3 +370,8 @@ export type Unit = { most_reported_officers?: SearchResponse[] sources?: Source[] } + +export type ComplaintsOverTimePoint = { + year: string + complaint_count: number +} diff --git a/frontend/utils/apiRoutes.ts b/frontend/utils/apiRoutes.ts index 4788e47d1..fd815bca9 100644 --- a/frontend/utils/apiRoutes.ts +++ b/frontend/utils/apiRoutes.ts @@ -36,6 +36,9 @@ const API_ROUTES = { }, units: { profile: (slug: string) => `/units/${slug}` + }, + complaints: { + metricsOverTime: "/complaints/metrics/over-time" } } From ea31f9a259ffb057189b625644e7b0548ca3629c Mon Sep 17 00:00:00 2001 From: puneet khanna Date: Fri, 10 Jul 2026 10:29:35 -0700 Subject: [PATCH 2/2] Update complaint count chart with sliding window controls and add login request --- frontend/app/overview/page.tsx | 167 ++++++++++++++++++++++++++++++++- 1 file changed, 162 insertions(+), 5 deletions(-) diff --git a/frontend/app/overview/page.tsx b/frontend/app/overview/page.tsx index dc83d7933..131dafbc9 100644 --- a/frontend/app/overview/page.tsx +++ b/frontend/app/overview/page.tsx @@ -1,17 +1,40 @@ "use client" -import { useEffect, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { LineChart } from "@mui/x-charts/LineChart" +import Slider from "@mui/material/Slider" +import ToggleButton from "@mui/material/ToggleButton" +import ToggleButtonGroup from "@mui/material/ToggleButtonGroup" import { apiFetch } from "@/utils/apiFetch" +import { useAuth } from "@/providers/AuthProvider" import { apiBaseUrl } from "@/utils/apiRoutes" import API_ROUTES from "@/utils/apiRoutes" import { ComplaintsOverTimePoint } from "@/utils/api" +const WINDOW_OPTIONS = [5, 10, 20] as const +type WindowSize = (typeof WINDOW_OPTIONS)[number] | "all" +const MIN_YEAR = 1990 +const MIN_VISIBLE_POINTS = 2 + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max) +} + export default function OverviewPage() { + const { accessToken, hasHydrated } = useAuth() const [data, setData] = useState(null) const [error, setError] = useState(null) + const [windowSize, setWindowSize] = useState("all") + const [range, setRange] = useState<[number, number]>([0, 0]) + const loginRequired = hasHydrated && !accessToken + useEffect(() => { + if (loginRequired) { + window.dispatchEvent(new Event("npdc:login-required-search")) + } + }, [loginRequired]) useEffect(() => { + if (!hasHydrated || !accessToken) return let active = true const loadData = async (): Promise => { @@ -21,8 +44,10 @@ export default function OverviewPage() { throw new Error(`Request failed with status ${res.status}`) } const json: ComplaintsOverTimePoint[] = await res.json() + const points = json.filter((point) => Number(point.year) >= MIN_YEAR) if (active) { - setData(json) + setData(points) + setRange([0, Math.max(0, points.length - 1)]) } } catch (err) { if (active) { @@ -35,7 +60,68 @@ export default function OverviewPage() { return () => { active = false } - }, []) + }, [hasHydrated, accessToken]) + + const pointCount = data?.length ?? 0 + const lastIndex = Math.max(0, pointCount - 1) + const selectedWindow: WindowSize = + windowSize !== "all" && windowSize < pointCount ? windowSize : "all" + const isAll = selectedWindow === "all" + const presetWidth = isAll ? pointCount : selectedWindow + const maxStartIndex = Math.max(0, pointCount - presetWidth) + const startIndex = clamp(range[0], 0, isAll ? lastIndex : maxStartIndex) + const endIndex = isAll + ? clamp(range[1], startIndex, lastIndex) + : Math.min(startIndex + presetWidth - 1, lastIndex) + + const visibleData = useMemo( + () => (data ?? []).slice(startIndex, endIndex + 1), + [data, startIndex, endIndex] + ) + + const handleWindowSizeChange = (_event: unknown, next: WindowSize | null): void => { + if (next === null) return + if (next === "all") { + setRange([0, lastIndex]) + setWindowSize(next) + return + } + + const width = Math.min(next, pointCount) + const nextStart = clamp(endIndex - width + 1, 0, Math.max(0, pointCount - width)) + setRange([nextStart, nextStart + width - 1]) + setWindowSize(next) + } + + const handleWindowSlide = (_event: unknown, value: number | number[]): void => { + const nextStart = value as number + setRange([nextStart, nextStart + presetWidth - 1]) + } + + const handleRangeChange = (_event: unknown, value: number | number[], thumb: number): void => { + const [nextStart, nextEnd] = value as [number, number] + if (nextEnd - nextStart >= MIN_VISIBLE_POINTS - 1) { + setRange([nextStart, nextEnd]) + } else if (thumb === 0) { + const start = Math.min(nextStart, lastIndex - (MIN_VISIBLE_POINTS - 1)) + setRange([start, start + MIN_VISIBLE_POINTS - 1]) + } else { + const end = Math.max(nextEnd, MIN_VISIBLE_POINTS - 1) + setRange([end - (MIN_VISIBLE_POINTS - 1), end]) + } + } + + if (!hasHydrated) { + return
Loading…
+ } + + if (loginRequired) { + return ( +
+ Please log in before searching. +
+ ) + } if (error) { return
Error: {error}
@@ -45,25 +131,96 @@ export default function OverviewPage() { return
Loading…
} + if (pointCount === 0) { + return
No complaint data available.
+ } + + const firstVisibleYear = visibleData[0]?.year + const lastVisibleYear = visibleData[visibleData.length - 1]?.year + return (

Complaints Over Time

+
+ + {WINDOW_OPTIONS.filter((option) => option < pointCount).map((option) => ( + + {option} yrs + + ))} + All + + + + {firstVisibleYear === lastVisibleYear + ? firstVisibleYear + : `${firstVisibleYear}–${lastVisibleYear}`} + +
+ point.year), + data: visibleData.map((point) => point.year), scaleType: "point", label: "Year" } ]} series={[ { - data: data.map((point) => point.complaint_count), + data: visibleData.map((point) => point.complaint_count), label: "Complaints" } ]} height={400} /> + +
+ {isAll ? ( + (index === 0 ? "First year shown" : "Last year shown")} + valueLabelDisplay="auto" + valueLabelFormat={(value) => data[value].year} + /> + ) : ( + + `${data[value].year}–${data[Math.min(value + presetWidth - 1, lastIndex)].year}` + } + /> + )} +
) }