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
29 changes: 29 additions & 0 deletions backend/routes/complaints.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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("/<complaint_uid>", methods=["PATCH"])
@jwt_required()
Expand Down
24 changes: 24 additions & 0 deletions backend/tests/test_complaints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
69 changes: 69 additions & 0 deletions frontend/app/overview/page.tsx
Original file line number Diff line number Diff line change
@@ -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<ComplaintsOverTimePoint[] | null>(null)
const [error, setError] = useState<string | null>(null)

useEffect(() => {
let active = true

const loadData = async (): Promise<void> => {
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 <div style={{ color: "red", padding: "1rem" }}>Error: {error}</div>
}

if (data === null) {
return <div style={{ padding: "1rem" }}>Loading…</div>
}

return (
<div style={{ padding: "2rem" }}>
<h1>Complaints Over Time</h1>
<LineChart
xAxis={[
{
data: data.map((point) => point.year),
scaleType: "point",
label: "Year"
}
]}
series={[
{
data: data.map((point) => point.complaint_count),
label: "Complaints"
}
]}
height={400}
/>
</div>
)
}
2 changes: 2 additions & 0 deletions frontend/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
"use client"
import styles from "./page.module.css"
import SearchBar from "@/components/SearchBar"
import Link from "next/link"

export default function Home() {
return (
<div className={styles.page}>
<section className={styles.hero}>
<h1 className={styles.heading}>How can we help you?</h1>
<SearchBar showLoginRequiredMessage />
<Link href="/overview">Click for an overview of complaint counts</Link>
</section>
</div>
)
Expand Down
1 change: 1 addition & 0 deletions frontend/components/Nav/nav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
1 change: 1 addition & 0 deletions frontend/components/Nav/nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
5 changes: 5 additions & 0 deletions frontend/utils/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,3 +370,8 @@ export type Unit = {
most_reported_officers?: SearchResponse[]
sources?: Source[]
}

export type ComplaintsOverTimePoint = {
year: string
complaint_count: number
}
3 changes: 3 additions & 0 deletions frontend/utils/apiRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ const API_ROUTES = {
},
units: {
profile: (slug: string) => `/units/${slug}`
},
complaints: {
metricsOverTime: "/complaints/metrics/over-time"
}
}

Expand Down
Loading