Repository: lnovitz/jobseeker-analytics
Pull request #346: Response rate frontend component
Author: login: m-banthwan
Labels: name: ⭐ goal: addition
description: Addition of new feature
name: 🕹 aspect: interface
description: Concerns end-users' experience with the software
name: size:L
description: This PR changes 100-499 lines, ignoring generated files.
name: lgtm
description: This PR has been approved by a maintainer
Created: 2025-03-24T00:20:31Z | Merged: 2025-03-31T03:36:17Z

# Pull Request

## Description
Added the code to display the Response Rate using mock data using a card component.

## Related Issue
<!-- Link to the issue that this PR addresses using the syntax: Fixes #123 -->
Fixes #291 

## Type of Change
<!-- Put an x in the boxes that apply -->
- [ ] 🐛 Bug fix (non-breaking change which fixes an issue)
- [ ] ✨ New feature (non-breaking change which adds functionality)
- [ ] 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] 📝 Documentation update
- [ ] 🎨 Style update (formatting, renaming)
- [ ] ♻️ Code refactor (no functional changes)
- [ ] ⚡️ Performance improvement
- [ ] ✅ Test update
- [ ] 🔨 Build configuration update
- [ ] 🔒 Security update

## Changes Made
<!-- List the changes made in this PR -->
1. 
2. 
3. 

## Screenshots
### Light mode
<img width="1355" alt="Screenshot 2025-03-24 at 12 22 06 PM" src="https://github.com/user-attachments/assets/4eeb7ea0-25bd-46ad-8004-e213db9a4743" />

### Dark mode
<img width="1330" alt="Screenshot 2025-03-24 at 12 21 19 PM" src="https://github.com/user-attachments/assets/a8911742-f1f9-45f0-b27a-278bc8ffe52e" />

## Testing
<!-- Describe the tests you ran and how to reproduce them -->
1. Steps to reproduce:
   - 
   - 
   - 
2. Test environment:
   - OS: 
   - Node version:
   - Browser (if applicable):

## Checklist
<!-- Put an x in the boxes that apply -->
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream modules
- [ ] I have checked my code and corrected any misspellings

## Dependencies
<!-- List any dependencies that are required for this change -->
- 
- 

## Database Changes
<!-- If your PR includes database changes, describe them here -->
- [ ] No database changes
- [ ] Database changes included (describe below):
  - 

## API Changes
<!-- If your PR includes API changes, describe them here -->
- [ ] No API changes
- [ ] API changes included (describe below):
  - 

## Performance Impact
<!-- Describe any performance impacts this change may have -->
- [ ] No performance impact
- [ ] Performance impact (describe below):
  - 

## Security Considerations
<!-- Describe any security implications this change may have -->
- [ ] No security implications
- [ ] Security implications considered (describe below):
  - 

## Additional Notes
<!-- Add any additional notes or context about the PR here -->

## Reviewer Notes
<!-- Notes for the reviewer to consider during review -->
- 
- 

## Post-Deployment Steps
<!-- List any steps needed after deploying this PR -->
- [ ] No post-deployment steps needed
- [ ] Post-deployment steps required (describe below):
  - 

---

By submitting this pull request, I confirm that my contribution is made under the terms of the project's license. 

Linked issue #123: Extract status per email content
LLM API

Diff:
diff --git a/backend/routes/users_routes.py b/backend/routes/users_routes.py
index ef304ba9..b8fab91b 100644
--- a/backend/routes/users_routes.py
+++ b/backend/routes/users_routes.py
@@ -1,10 +1,14 @@
 import logging
-from fastapi import APIRouter, Depends, Request
+from fastapi import APIRouter, Depends, Request, HTTPException
 from sqlmodel import Session, select
 from db.user_emails import UserEmails
 from utils.config_utils import get_settings
 from session.session_layer import validate_session
+from routes.email_routes import query_emails
 from database import engine
+from slowapi import Limiter
+from slowapi.util import get_remote_address
+
 
 # Logger setup
 logger = logging.getLogger(__name__)
@@ -17,7 +21,56 @@
 
 # FastAPI router for email routes
 router = APIRouter()
+limiter = Limiter(key_func=get_remote_address)
+
+@router.get("/get-response-rate")   
+@limiter.limit("2/minute")    
+def response_rate_by_job_title(request: Request, user_id: str = Depends(validate_session)):
+    
+    try:
+        # Get job related email data from DB
+        user_emails = query_emails(request, user_id)
+
+        index = 0
+
+        # Tracks all job titles and their index in response_rate
+        job_titles = {}
+
+        # Store (company, job_title) tuples to avoid duplicates
+        companies = []
+
+        # List of dictionaries to store job titles and their response rates
+        response_rate_data = []
+
+        for email in user_emails:
+            if email.job_title not in job_titles:
+                status = email.application_status.strip().lower()
+                if status == "request for availability" or status == "offer" or status == "interview scheduled":
+                    response_rate_data.append({"title": email.job_title, "responses": 1, "total": 1})
+                else:
+                    response_rate_data.append({"title": email.job_title, "responses": 0, "total": 1})
+                companies.append((email.company_name, email.job_title))
+                job_titles[email.job_title] = index
+                index += 1
+            elif (email.company_name, email.job_title) not in companies:
+                status = email.application_status.strip().lower()
+                if status == "request for availability" or status == "offer" or status == "interview scheduled":
+                    response_rate_data[job_titles[email.job_title]]["responses"] += 1
+                response_rate_data[job_titles[email.job_title]]["total"] += 1
+                companies.append((email.company_name, email.job_title))
+
+        response_rate = []
+        for data in response_rate_data:
+            response_rate.append({
+                "title": data["title"],
+                "rate": round(data["responses"] / data["total"] * 100, 2)
+            })
 
+        return response_rate
+    
+    except Exception as e:
+        logger.error(f"Error fetching job titles for user_id {user_id}: {e}")
+        raise HTTPException(status_code=500, detail=f"An error occurred: {str(e)}")
 
 @router.get("/user-response-rate")
 def calculate_response_rate(
@@ -35,7 +88,7 @@ def calculate_response_rate(
 
         interview_requests = 0
         for email in user_emails:
-            # using request for avalability as an interview request as it should come before the offer and shecduled interview
+            # using request for avalability as an interview request as it should come before the offer and scheduled interview
             if (
                 email.application_status
                 and email.application_status.lower() == "request for availability"
@@ -43,4 +96,5 @@ def calculate_response_rate(
                 interview_requests += 1
 
         response_rate_percent = (interview_requests / total_apps) * 100
-        return round(response_rate_percent, 1)
+        return {"value": round(response_rate_percent, 1)}
+    
diff --git a/frontend/app/error.tsx b/frontend/app/error.tsx
index 3c75b0c8..93f929da 100644
--- a/frontend/app/error.tsx
+++ b/frontend/app/error.tsx
@@ -5,7 +5,7 @@ import { useEffect } from "react";
 export default function Error({ error, reset }: { error: Error; reset: () => void }) {
 	useEffect(() => {
 		// Log the error to an error reporting service
-		/* eslint-disable no-console */
+		// eslint-disable-next-line no-console
 		console.error(error);
 	}, [error]);
 
diff --git a/frontend/app/errors/page.tsx b/frontend/app/errors/page.tsx
index a6fc01db..47791177 100644
--- a/frontend/app/errors/page.tsx
+++ b/frontend/app/errors/page.tsx
@@ -53,6 +53,8 @@ function ErrorContent() {
 				);
 			}
 		} catch (err) {
+			// eslint-disable-next-line no-console
+			console.error("Error in useEffect:", err);
 			setErrorMessage(`Error page encountered an error: ${err instanceof Error ? err.message : String(err)}`);
 		}
 	}, [searchParams]);
diff --git a/frontend/components/JobApplicationsDashboard.tsx b/frontend/components/JobApplicationsDashboard.tsx
index 0b3c20d0..eddd68e6 100644
--- a/frontend/components/JobApplicationsDashboard.tsx
+++ b/frontend/components/JobApplicationsDashboard.tsx
@@ -21,6 +21,8 @@ import {
 } from "@heroui/react";
 
 import { DownloadIcon, SortIcon, TrashIcon } from "@/components/icons";
+import ResponseRateCard from "@/components/response_rate_card";
+import UniqueOpenRateChart from "@/components/response_rate_chart";
 
 export interface Application {
 	id?: string;
@@ -186,6 +188,7 @@ export default function JobApplicationsDashboard({
 			</Modal>
 
 			{extraHeader}
+
 			<Modal isOpen={showDelete} onOpenChange={(isOpen) => setShowDelete(isOpen)}>
 				<ModalContent>
 					{(onClose) => (
@@ -210,63 +213,72 @@ export default function JobApplicationsDashboard({
 					)}
 				</ModalContent>
 			</Modal>
-			<div className="flex items-center justify-between mb-4">
-				<h1 className="text-2xl font-bold">{title}</h1>
-				<div className="flex gap-x-4">
-					<Dropdown>
-						<DropdownTrigger>
-							<Button
-								className="pl-3"
-								color="primary"
-								isDisabled={!data || data.length === 0}
-								startContent={<SortIcon />}
-								variant="bordered"
-							>
-								{selectedValue}
-							</Button>
-						</DropdownTrigger>
-						<DropdownMenu
-							disallowEmptySelection
-							aria-label="Single selection example"
-							selectedKeys={selectedKeys}
-							selectionMode="single"
-							variant="flat"
-							onSelectionChange={(keys) => handleSortChange(keys as Set<string>)}
+			<h1 className="text-2xl font-bold mt-0">{title}</h1>
+			{extraHeader}
+			<div className="flex flex-col gap-4 mt-4 mb-6 md:flex-row">
+				<div className="w-full md:w-[30%]">
+					<ResponseRateCard />
+				</div>
+				<div className="md:w-[70%]">
+					<UniqueOpenRateChart />
+				</div>
+			</div>
+			<div className="flex flex-wrap items-center justify-end gap-4 mb-4">
+				<Dropdown>
+					<DropdownTrigger>
+						<Button
+							className="pl-3"
+							color="primary"
+							isDisabled={!data || data.length === 0}
+							startContent={<SortIcon />}
+							variant="bordered"
 						>
-							<DropdownSection title="Sort By">
-								<DropdownItem key="Date (Newest)">Date Received (Newest First)</DropdownItem>
-								<DropdownItem key="Date (Oldest)">Date Received (Oldest First)</DropdownItem>
-								<DropdownItem key="Company">Company (A-Z)</DropdownItem>
-								<DropdownItem key="Job Title">Job Title (A-Z)</DropdownItem>
-								<DropdownItem key="Status">Application Status</DropdownItem>
-							</DropdownSection>
-						</DropdownMenu>
-					</Dropdown>
-					<Button
-						color="primary"
-						isDisabled={!data || data.length === 0}
-						isLoading={downloading}
-						startContent={<DownloadIcon />}
-						onPress={onDownloadSankey}
-					>
-						Download Sankey Diagram
-					</Button>
-					<Button
-						color="success"
-						isDisabled={!data || data.length === 0}
-						isLoading={downloading}
-						startContent={<DownloadIcon />}
-						onPress={onDownloadCsv}
+							{selectedValue}
+						</Button>
+					</DropdownTrigger>
+					<DropdownMenu
+						disallowEmptySelection
+						aria-label="Single selection example"
+						selectedKeys={selectedKeys}
+						selectionMode="single"
+						variant="flat"
+						onSelectionChange={(keys) => handleSortChange(keys as Set<string>)}
 					>
-						Download CSV
-					</Button>
-				</div>
+						<DropdownSection title="Sort By">
+							<DropdownItem key="Date (Newest)">Date Received (Newest First)</DropdownItem>
+							<DropdownItem key="Date (Oldest)">Date Received (Oldest First)</DropdownItem>
+							<DropdownItem key="Company">Company (A-Z)</DropdownItem>
+							<DropdownItem key="Job Title">Job Title (A-Z)</DropdownItem>
+							<DropdownItem key="Status">Application Status</DropdownItem>
+						</DropdownSection>
+					</DropdownMenu>
+				</Dropdown>
+				<Button
+					className="w-full sm:w-auto text-white"
+					color="primary"
+					isDisabled={!data || data.length === 0}
+					isLoading={downloading}
+					startContent={<DownloadIcon />}
+					onPress={onDownloadSankey}
+				>
+					Download Sankey Diagram
+				</Button>
+				<Button
+					className="w-full sm:w-auto text-white"
+					color="success"
+					isDisabled={!data || data.length === 0}
+					isLoading={downloading}
+					startContent={<DownloadIcon />}
+					onPress={onDownloadCsv}
+				>
+					Download CSV
+				</Button>
 			</div>
 
 			{loading ? (
 				<p>Loading applications...</p>
 			) : (
-				<div className="overflow-x-auto bg-white shadow-md rounded-lg">
+				<div className="overflow-x-auto bg-white dark:bg-black shadow-md rounded-lg">
 					<Table aria-label="Applications Table">
 						<TableHeader>
 							<TableColumn>Company</TableColumn>
diff --git a/frontend/components/icons.tsx b/frontend/components/icons.tsx
index b62bce08..478a2ec6 100644
--- a/frontend/components/icons.tsx
+++ b/frontend/components/icons.tsx
@@ -275,6 +275,23 @@ export const CheckCircleIcon: React.FC<IconSvgProps> = ({ size = 24, width, heig
 	</svg>
 );
 
+export const ClockIcon: React.FC<IconSvgProps> = ({ size = 16, width, height, ...props }) => (
+	<svg
+		fill="none"
+		height={size || height}
+		stroke="currentColor"
+		strokeLinecap="round"
+		strokeLinejoin="round"
+		strokeWidth="2"
+		viewBox="0 0 24 24"
+		width={size || width}
+		{...props}
+	>
+		<circle cx="12" cy="12" r="10" />
+		<polyline points="12 6 12 12 16 14" />
+	</svg>
+);
+
 export const TrashIcon: React.FC<IconSvgProps> = ({ size = 24, width, height, ...props }) => (
 	<svg
 		height={size || height}
diff --git a/frontend/components/response_rate_card.tsx b/frontend/components/response_rate_card.tsx
new file mode 100644
index 00000000..d91c8a5f
--- /dev/null
+++ b/frontend/components/response_rate_card.tsx
@@ -0,0 +1,82 @@
+"use client";
+import { useState, useEffect } from "react";
+import { addToast } from "@heroui/react";
+
+import { ClockIcon } from "@/components/icons";
+
+export default function ResponseRateCard() {
+	const [lastUpdated, setLastUpdated] = useState<string>("");
+	const [value, setValue] = useState(0);
+
+	const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/";
+
+	useEffect(() => {
+		const fetchData = async () => {
+			try {
+				const response = await fetch(`${apiUrl}/user-response-rate`, {
+					method: "GET",
+					credentials: "include" // Include cookies for session management
+				});
+
+				if (!response.ok) {
+					addToast({
+						title: "An error occurred while loading the response rate",
+						description: "Please try again or contact help@jobba.help if the issue persists.",
+						color: "danger"
+					});
+					return;
+				}
+				const result = await response.json();
+				setValue(result?.value ?? 0);
+				setLastUpdated(formatDate(new Date()));
+			} catch {
+				addToast({
+					title: "Connection Error",
+					description: "Failed to fetch response rate data",
+					color: "danger"
+				});
+			}
+		};
+
+		fetchData();
+	}, []);
+
+	// Helper function to format the date
+	const formatDate = (date: Date): string => {
+		const now = new Date();
+		const yesterday = new Date();
+		yesterday.setDate(now.getDate() - 1);
+
+		const isToday = date.toDateString() === now.toDateString();
+		const isYesterday = date.toDateString() === yesterday.toDateString();
+
+		const timeFormatter = new Intl.DateTimeFormat("en-US", {
+			hour: "numeric",
+			minute: "numeric",
+			hour12: true
+		});
+
+		if (isToday) {
+			return `Today, ${timeFormatter.format(date)}`;
+		} else if (isYesterday) {
+			return `Yesterday, ${timeFormatter.format(date)}`;
+		} else {
+			const dateFormatter = new Intl.DateTimeFormat("en-US", {
+				month: "short",
+				day: "numeric"
+			});
+			return `${dateFormatter.format(date)}, ${timeFormatter.format(date)}`;
+		}
+	};
+
+	return (
+		<div className="flex flex-col justify-center bg-gray-100 dark:bg-gray-800 shadow-md rounded-lg p-7 md:h-[350px]">
+			<p className="text-8xl font-bold text-blue-600 dark:text-blue-400 mb-1">{value}%</p>
+			<h3 className="text-xl font-medium text-gray-700 dark:text-gray-300 mb-4">Response Rate</h3>
+			<div className="flex gap-2 text-base text-gray-500 dark:text-gray-400">
+				<ClockIcon className="self-center" />
+				<span>Last Updated: {lastUpdated}</span>
+			</div>
+		</div>
+	);
+}
diff --git a/frontend/components/response_rate_chart.tsx b/frontend/components/response_rate_chart.tsx
new file mode 100644
index 00000000..b80be7e5
--- /dev/null
+++ b/frontend/components/response_rate_chart.tsx
@@ -0,0 +1,152 @@
+"use client";
+import { BarChart, Bar, XAxis, YAxis, Cell, ResponsiveContainer, Tooltip, CartesianGrid } from "recharts";
+import { useState, useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { addToast } from "@heroui/react";
+
+interface ResponseData {
+	title: string;
+	rate: number;
+}
+
+const BLUE_COLOR = "#3b82f6";
+
+export default function JobTitleResponseChart() {
+	const router = useRouter();
+	const [activeIndex, setActiveIndex] = useState<number | null>(null);
+	const [data, setData] = useState<ResponseData[]>([]);
+	const [isDarkMode, setIsDarkMode] = useState(false);
+
+	const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
+
+	useEffect(() => {
+		const checkDarkMode = () => {
+			setIsDarkMode(document.documentElement.classList.contains("dark"));
+		};
+
+		checkDarkMode();
+
+		const observer = new MutationObserver(checkDarkMode);
+		observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
+
+		return () => observer.disconnect();
+	}, []);
+
+	useEffect(() => {
+		const fetchData = async () => {
+			try {
+				const response = await fetch(`${apiUrl}/get-response-rate`, {
+					method: "GET",
+					credentials: "include" // Include cookies for session management
+				});
+
+				if (!response.ok) {
+					addToast({
+						title: "An error occurred while loading the response rate",
+						description: "Please try again or contact help@jobba.help if the issue persists.",
+						color: "danger"
+					});
+					return;
+				}
+
+				const result = await response.json();
+
+				if (result.length === 0) {
+					console.warn("Empty response");
+				} else {
+					setData(result);
+				}
+			} catch {
+				addToast({
+					title: "Connection Error",
+					description: "Failed to fetch response rate data",
+					color: "danger"
+				});
+			}
+		};
+
+		fetchData();
+	}, [router]);
+
+	return (
+		<div className="bg-gray-100 dark:bg-gray-800 p-6 shadow-md rounded-lg">
+			<div className="flex justify-between items-center mb-6">
+				<h2 className="text-gray-700 dark:text-gray-300 text-base md:text-xl font-semibold">
+					Response Rate based on Job Titles
+				</h2>
+			</div>
+
+			<div className="h-[250px] overflow-x-auto">
+				<div className="min-w-[600px] h-full">
+					<ResponsiveContainer height="100%" width="100%">
+						<BarChart
+							data={data}
+							margin={{ top: 5, right: 20, left: 0, bottom: 70 }}
+							onMouseLeave={() => setActiveIndex(null)}
+						>
+							<defs>
+								<linearGradient id="barGradient" x1="0" x2="0" y1="0" y2="1">
+									<stop offset="0%" stopColor="#60a5fa" /> {/* Lighter blue */}
+									<stop offset="100%" stopColor={BLUE_COLOR} /> {/* Your exact blue */}
+								</linearGradient>
+							</defs>
+
+							<YAxis
+								axisLine={{ stroke: "#334155" }}
+								domain={[0, 100]}
+								tick={{ fill: "#94a3b8", fontSize: 12 }}
+								tickLine={{ stroke: "#334155" }}
+								type="number"
+							/>
+
+							<CartesianGrid horizontal={true} stroke="#1e293b" strokeDasharray="3 3" vertical={false} />
+
+							<XAxis
+								angle={-45}
+								axisLine={{ stroke: "#334155" }}
+								dataKey="title"
+								height={30}
+								interval={0}
+								textAnchor="end"
+								tick={{ fill: isDarkMode ? "#e0e0e0" : "#616161", fontSize: 12 }}
+								tickLine={{ stroke: "#334155" }}
+								type="category"
+							/>
+
+							<Tooltip
+								contentStyle={{
+									background: "#1e293b",
+									borderColor: BLUE_COLOR, // Blue border to match
+									borderRadius: "6px",
+									color: "#f8fafc"
+								}}
+								cursor={{ fill: "rgba(59, 130, 246, 0.1)" }} // Blue tint
+								formatter={(value: number) => [`${value}%`, "Response Rate"]}
+								itemStyle={{
+									color: "#f8fafc" // Ensures tooltip items (formatter output) are white
+								}}
+							/>
+
+							<Bar
+								animationDuration={1000}
+								dataKey="rate"
+								name="Response Rate"
+								radius={[4, 4, 0, 0]}
+								onMouseEnter={(_, index) => setActiveIndex(index)}
+							>
+								{data.map((entry, index) => (
+									<Cell
+										key={`cell-${index}`}
+										fill={index === activeIndex ? "url(#barGradient)" : BLUE_COLOR}
+										stroke={index === activeIndex ? "#93c5fd" : "none"} // Light blue border on hover
+										strokeWidth={index === activeIndex ? 2 : 0}
+									/>
+								))}
+							</Bar>
+						</BarChart>
+					</ResponsiveContainer>
+				</div>
+			</div>
+		</div>
+	);
+}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 5dba622d..ad31dec0 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -25,7 +25,8 @@
 				"posthog-js": "^1.231.2",
 				"react": "18.3.1",
 				"react-dom": "18.3.1",
-				"react-google-recaptcha": "^3.1.0"
+				"react-google-recaptcha": "^3.1.0",
+				"recharts": "^2.15.1"
 			},
 			"devDependencies": {
 				"@eslint/js": "^9.22.0",
@@ -5288,6 +5289,60 @@
 				"tslib": "^2.4.0"
 			}
 		},
+		"node_modules/@types/d3-array": {
+			"version": "3.2.1",
+			"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
+			"integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="
+		},
+		"node_modules/@types/d3-color": {
+			"version": "3.1.3",
+			"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+			"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
+		},
+		"node_modules/@types/d3-ease": {
+			"version": "3.0.2",
+			"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+			"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
+		},
+		"node_modules/@types/d3-interpolate": {
+			"version": "3.0.4",
+			"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+			"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+			"dependencies": {
+				"@types/d3-color": "*"
+			}
+		},
+		"node_modules/@types/d3-path": {
+			"version": "3.1.1",
+			"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+			"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="
+		},
+		"node_modules/@types/d3-scale": {
+			"version": "4.0.9",
+			"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+			"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+			"dependencies": {
+				"@types/d3-time": "*"
+			}
+		},
+		"node_modules/@types/d3-shape": {
+			"version": "3.1.7",
+			"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
+			"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
+			"dependencies": {
+				"@types/d3-path": "*"
+			}
+		},
+		"node_modules/@types/d3-time": {
+			"version": "3.0.4",
+			"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+			"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
+		},
+		"node_modules/@types/d3-timer": {
+			"version": "3.0.2",
+			"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+			"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
+		},
 		"node_modules/@types/json5": {
 			"version": "0.0.29",
 			"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
@@ -6218,8 +6273,117 @@
 		"node_modules/csstype": {
 			"version": "3.1.3",
 			"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
-			"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
-			"dev": true
+			"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
+		},
+		"node_modules/d3-array": {
+			"version": "3.2.4",
+			"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+			"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+			"dependencies": {
+				"internmap": "1 - 2"
+			},
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-color": {
+			"version": "3.1.0",
+			"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+			"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-ease": {
+			"version": "3.0.1",
+			"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+			"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-format": {
+			"version": "3.1.0",
+			"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
+			"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-interpolate": {
+			"version": "3.0.1",
+			"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+			"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+			"dependencies": {
+				"d3-color": "1 - 3"
+			},
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-path": {
+			"version": "3.1.0",
+			"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+			"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-scale": {
+			"version": "4.0.2",
+			"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+			"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+			"dependencies": {
+				"d3-array": "2.10.0 - 3",
+				"d3-format": "1 - 3",
+				"d3-interpolate": "1.2.0 - 3",
+				"d3-time": "2.1.1 - 3",
+				"d3-time-format": "2 - 4"
+			},
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-shape": {
+			"version": "3.2.0",
+			"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+			"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+			"dependencies": {
+				"d3-path": "^3.1.0"
+			},
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-time": {
+			"version": "3.1.0",
+			"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+			"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+			"dependencies": {
+				"d3-array": "2 - 3"
+			},
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-time-format": {
+			"version": "4.1.0",
+			"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+			"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+			"dependencies": {
+				"d3-time": "1 - 3"
+			},
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-timer": {
+			"version": "3.0.1",
+			"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+			"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+			"engines": {
+				"node": ">=12"
+			}
 		},
 		"node_modules/damerau-levenshtein": {
 			"version": "1.0.8",
@@ -6300,6 +6464,11 @@
 			"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz",
 			"integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw=="
 		},
+		"node_modules/decimal.js-light": {
+			"version": "2.5.1",
+			"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+			"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
+		},
 		"node_modules/deep-is": {
 			"version": "0.1.4",
 			"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -6379,6 +6548,15 @@
 				"node": ">=6.0.0"
 			}
 		},
+		"node_modules/dom-helpers": {
+			"version": "5.2.1",
+			"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+			"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+			"dependencies": {
+				"@babel/runtime": "^7.8.7",
+				"csstype": "^3.0.2"
+			}
+		},
 		"node_modules/dunder-proto": {
 			"version": "1.0.1",
 			"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -7238,12 +7416,25 @@
 				"node": ">=0.10.0"
 			}
 		},
+		"node_modules/eventemitter3": {
+			"version": "4.0.7",
+			"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
+			"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
+		},
 		"node_modules/fast-deep-equal": {
 			"version": "3.1.3",
 			"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
 			"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
 			"dev": true
 		},
+		"node_modules/fast-equals": {
+			"version": "5.2.2",
+			"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz",
+			"integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==",
+			"engines": {
+				"node": ">=6.0.0"
+			}
+		},
 		"node_modules/fast-glob": {
 			"version": "3.3.1",
 			"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
@@ -7841,6 +8032,14 @@
 				"node": ">= 0.4"
 			}
 		},
+		"node_modules/internmap": {
+			"version": "2.0.3",
+			"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+			"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+			"engines": {
+				"node": ">=12"
+			}
+		},
 		"node_modules/intl-messageformat": {
 			"version": "10.7.15",
 			"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.15.tgz",
@@ -8427,6 +8626,11 @@
 				"url": "https://github.com/sponsors/sindresorhus"
 			}
 		},
+		"node_modules/lodash": {
+			"version": "4.17.21",
+			"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+			"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
+		},
 		"node_modules/lodash.merge": {
 			"version": "4.6.2",
 			"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -9306,6 +9510,20 @@
 			"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
 			"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
 		},
+		"node_modules/react-smooth": {
+			"version": "4.0.4",
+			"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
+			"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
+			"dependencies": {
+				"fast-equals": "^5.0.1",
+				"prop-types": "^15.8.1",
+				"react-transition-group": "^4.4.5"
+			},
+			"peerDependencies": {
+				"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+				"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+			}
+		},
 		"node_modules/react-textarea-autosize": {
 			"version": "8.5.8",
 			"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.8.tgz",
@@ -9322,6 +9540,21 @@
 				"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
 			}
 		},
+		"node_modules/react-transition-group": {
+			"version": "4.4.5",
+			"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
+			"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
+			"dependencies": {
+				"@babel/runtime": "^7.5.5",
+				"dom-helpers": "^5.0.1",
+				"loose-envify": "^1.4.0",
+				"prop-types": "^15.6.2"
+			},
+			"peerDependencies": {
+				"react": ">=16.6.0",
+				"react-dom": ">=16.6.0"
+			}
+		},
 		"node_modules/read-cache": {
 			"version": "1.0.0",
 			"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -9341,6 +9574,41 @@
 				"node": ">=8.10.0"
 			}
 		},
+		"node_modules/recharts": {
+			"version": "2.15.1",
+			"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.1.tgz",
+			"integrity": "sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==",
+			"dependencies": {
+				"clsx": "^2.0.0",
+				"eventemitter3": "^4.0.1",
+				"lodash": "^4.17.21",
+				"react-is": "^18.3.1",
+				"react-smooth": "^4.0.4",
+				"recharts-scale": "^0.4.4",
+				"tiny-invariant": "^1.3.1",
+				"victory-vendor": "^36.6.8"
+			},
+			"engines": {
+				"node": ">=14"
+			},
+			"peerDependencies": {
+				"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+				"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+			}
+		},
+		"node_modules/recharts-scale": {
+			"version": "0.4.5",
+			"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
+			"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
+			"dependencies": {
+				"decimal.js-light": "^2.4.1"
+			}
+		},
+		"node_modules/recharts/node_modules/react-is": {
+			"version": "18.3.1",
+			"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+			"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
+		},
 		"node_modules/reflect.getprototypeof": {
 			"version": "1.0.10",
 			"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -10205,6 +10473,11 @@
 				"node": ">=0.8"
 			}
 		},
+		"node_modules/tiny-invariant": {
+			"version": "1.3.3",
+			"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+			"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
+		},
 		"node_modules/tinyglobby": {
 			"version": "0.2.12",
 			"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.12.tgz",
@@ -10761,6 +11034,27 @@
 			"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
 			"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
 		},
+		"node_modules/victory-vendor": {
+			"version": "36.9.2",
+			"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
+			"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
+			"dependencies": {
+				"@types/d3-array": "^3.0.3",
+				"@types/d3-ease": "^3.0.0",
+				"@types/d3-interpolate": "^3.0.1",
+				"@types/d3-scale": "^4.0.2",
+				"@types/d3-shape": "^3.1.0",
+				"@types/d3-time": "^3.0.0",
+				"@types/d3-timer": "^3.0.0",
+				"d3-array": "^3.1.6",
+				"d3-ease": "^3.0.1",
+				"d3-interpolate": "^3.0.1",
+				"d3-scale": "^4.0.2",
+				"d3-shape": "^3.1.0",
+				"d3-time": "^3.0.0",
+				"d3-timer": "^3.0.1"
+			}
+		},
 		"node_modules/web-vitals": {
 			"version": "4.2.4",
 			"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 0f343baf..f7df2192 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -30,7 +30,8 @@
 		"posthog-js": "^1.231.2",
 		"react": "18.3.1",
 		"react-dom": "18.3.1",
-		"react-google-recaptcha": "^3.1.0"
+		"react-google-recaptcha": "^3.1.0",
+		"recharts": "^2.15.1"
 	},
 	"devDependencies": {
 		"@eslint/js": "^9.22.0",


Reviews:
user: login: lnovitz
body: Frontend looks good but can it live in the dashboard page above the table? I don't want separate pages

![image](https://github.com/user-attachments/assets/18f6fc59-c536-469e-9379-2ab226a02ad9)
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-03-24T17:51:12Z
user: login: lnovitz
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-27T21:19:13Z
user: login: lnovitz
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-27T21:23:25Z
user: login: lnovitz
body: Icon fix and remove unused code, looks great so far though
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-03-27T21:24:49Z
user: login: lnovitz
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-29T23:17:22Z
user: login: lnovitz
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-29T23:17:36Z
user: login: happynasit
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-30T04:24:35Z
user: login: happynasit
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-30T04:24:40Z
user: login: lnovitz
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-30T16:43:59Z
user: login: lnovitz
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-30T16:49:14Z
user: login: jediela
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-30T20:35:47Z
user: login: jediela
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-30T20:36:57Z
user: login: lnovitz
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-30T22:31:45Z
user: login: lnovitz
body: Rate limiting needed
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-03-30T22:33:32Z
user: login: lnovitz
body: Merge conflictsssss
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-03-31T03:03:29Z
user: login: lnovitz
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-31T03:35:48Z

Review comments:
diff_hunk: @@ -0,0 +1,10 @@
+import ResponseRateCard from "../../components/response_rate_card";
+
+export default function ResponseRates() {
+	return (
+		<div>
+			<h1>Response Rates Page</h1>
+			<ResponseRateCard />
+		</div>
+	);
+}
path: frontend/app/response-rates/page.tsx
user: login: lnovitz
body: Can we remove this page if it's not being used?
created_at: 2025-03-27T21:19:13Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,91 @@
+"use client";
+import { useState, useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { Clock } from "lucide-react";
path: frontend/components/response_rate_card.tsx
user: login: lnovitz
body: Can you create an SVG icon instead? @jediela has been placing icons in the `icons.tsx` file. Since we don't use `lucide-react` anywhere else, probably better performance wise to avoid importing.

<img width="570" alt="Screenshot 2025-03-27 at 2 22 31 PM" src="https://github.com/user-attachments/assets/917811ac-1375-4fce-9046-7b63f240098c" />

created_at: 2025-03-27T21:23:25Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,148 @@
+"use client";
+import { BarChart, Bar, XAxis, YAxis, Cell, ResponsiveContainer, Tooltip, CartesianGrid } from "recharts";
+import { useState, useEffect } from "react";
+import { useRouter } from "next/navigation";
+
+interface ResponseData {
+	title: string;
+	rate: number;
+}
+
+const BLUE_COLOR = "#3b82f6";
+
+export default function JobTitleResponseChart() {
+	const router = useRouter();
+	const [activeIndex, setActiveIndex] = useState<number | null>(null);
+	const [data, setData] = useState<ResponseData[]>([]);
+	const [isDarkMode, setIsDarkMode] = useState(false);
+
+	const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
+
+	useEffect(() => {
+		const checkDarkMode = () => {
+			setIsDarkMode(document.documentElement.classList.contains("dark"));
+		};
+
+		checkDarkMode();
+
+		const observer = new MutationObserver(checkDarkMode);
+		observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
+
+		return () => observer.disconnect();
+	}, []);
+
+	useEffect(() => {
+		const fetchData = async () => {
+			try {
+				const response = await fetch(`${apiUrl}/get-response-rate`, {
+					method: "GET",
+					credentials: "include" // Include cookies for session management
+				});
+
+				if (!response.ok) {
+					if (response.status === 404) {
+						// No data found
+					} else {
+						throw new Error(`HTTP error! status: ${response.status}`);
+					}
+				}
+
+				const result = await response.json();
+
+				if (result.length === 0) {
+					// No data found
+				} else {
+					setData(result);
+				}
+			} catch {
+				// Failed to load data
+			} finally {
+				// Set loading to false
path: frontend/components/response_rate_chart.tsx
user: login: lnovitz
body: Is there something that needs to be done here? This looks like todos
created_at: 2025-03-29T23:17:22Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,93 @@
+"use client";
+import { useState, useEffect } from "react";
+import { useRouter } from "next/navigation";
+
+import { ClockIcon } from "@/components/icons";
+
+export default function ResponseRateCard() {
+	const [lastUpdated, setLastUpdated] = useState<string>("");
+
+	const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/";
+	const [value, setValue] = useState(null);
+	const router = useRouter();
+
+	useEffect(() => {
+		const fetchData = async () => {
+			try {
+				const response = await fetch(`${apiUrl}/user-response-rate`, {
+					method: "GET",
+					credentials: "include" // Include cookies for session management
+				});
+
+				if (!response.ok) {
+					if (response.status === 404) {
+						// No data found
+					} else {
+						throw new Error(`HTTP error! status: ${response.status}`);
+					}
+				}
+				const result = await response.json();
+				// eslint-disable-next-line no-console
+				console.log(result.value);
+
+				if (result.length === 0) {
+					// No data found
+				} else {
+					setValue(result.value);
+				}
+				// Set the last updated time (assuming the API provides a timestamp)
+				if (result.lastUpdated) {
+					setLastUpdated(formatDate(new Date(result.lastUpdated)));
+				} else {
+					// Fallback to the current time if no timestamp is provided
+					setLastUpdated(formatDate(new Date()));
+				}
+			} catch {
+				// Failed to load data
+			} finally {
+				// Set loading to false
path: frontend/components/response_rate_card.tsx
user: login: lnovitz
body: Todos??
created_at: 2025-03-29T23:17:36Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,148 @@
+"use client";
+import { BarChart, Bar, XAxis, YAxis, Cell, ResponsiveContainer, Tooltip, CartesianGrid } from "recharts";
+import { useState, useEffect } from "react";
+import { useRouter } from "next/navigation";
+
+interface ResponseData {
+	title: string;
+	rate: number;
+}
+
+const BLUE_COLOR = "#3b82f6";
+
+export default function JobTitleResponseChart() {
+	const router = useRouter();
+	const [activeIndex, setActiveIndex] = useState<number | null>(null);
+	const [data, setData] = useState<ResponseData[]>([]);
+	const [isDarkMode, setIsDarkMode] = useState(false);
+
+	const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
+
+	useEffect(() => {
+		const checkDarkMode = () => {
+			setIsDarkMode(document.documentElement.classList.contains("dark"));
+		};
+
+		checkDarkMode();
+
+		const observer = new MutationObserver(checkDarkMode);
+		observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
+
+		return () => observer.disconnect();
+	}, []);
+
+	useEffect(() => {
+		const fetchData = async () => {
+			try {
+				const response = await fetch(`${apiUrl}/get-response-rate`, {
+					method: "GET",
+					credentials: "include" // Include cookies for session management
+				});
+
+				if (!response.ok) {
+					if (response.status === 404) {
+						// No data found
+					} else {
+						throw new Error(`HTTP error! status: ${response.status}`);
+					}
+				}
+
+				const result = await response.json();
+
+				if (result.length === 0) {
+					// No data found
+				} else {
+					setData(result);
+				}
+			} catch {
+				// Failed to load data
+			} finally {
+				// Set loading to false
path: frontend/components/response_rate_chart.tsx
user: login: happynasit
body: Updated the code and added some console errors 
created_at: 2025-03-30T04:24:35Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,93 @@
+"use client";
+import { useState, useEffect } from "react";
+import { useRouter } from "next/navigation";
+
+import { ClockIcon } from "@/components/icons";
+
+export default function ResponseRateCard() {
+	const [lastUpdated, setLastUpdated] = useState<string>("");
+
+	const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/";
+	const [value, setValue] = useState(null);
+	const router = useRouter();
+
+	useEffect(() => {
+		const fetchData = async () => {
+			try {
+				const response = await fetch(`${apiUrl}/user-response-rate`, {
+					method: "GET",
+					credentials: "include" // Include cookies for session management
+				});
+
+				if (!response.ok) {
+					if (response.status === 404) {
+						// No data found
+					} else {
+						throw new Error(`HTTP error! status: ${response.status}`);
+					}
+				}
+				const result = await response.json();
+				// eslint-disable-next-line no-console
+				console.log(result.value);
+
+				if (result.length === 0) {
+					// No data found
+				} else {
+					setValue(result.value);
+				}
+				// Set the last updated time (assuming the API provides a timestamp)
+				if (result.lastUpdated) {
+					setLastUpdated(formatDate(new Date(result.lastUpdated)));
+				} else {
+					// Fallback to the current time if no timestamp is provided
+					setLastUpdated(formatDate(new Date()));
+				}
+			} catch {
+				// Failed to load data
+			} finally {
+				// Set loading to false
path: frontend/components/response_rate_card.tsx
user: login: happynasit
body: Updated the code and added some console errors 
created_at: 2025-03-30T04:24:40Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,97 @@
+"use client";
+import { useState, useEffect } from "react";
+import { useRouter } from "next/navigation";
+
+import { ClockIcon } from "@/components/icons";
+
+export default function ResponseRateCard() {
+	const [lastUpdated, setLastUpdated] = useState<string>("");
+
+	const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/";
+	const [value, setValue] = useState(null);
+	const router = useRouter();
+
+	useEffect(() => {
+		const fetchData = async () => {
+			try {
+				const response = await fetch(`${apiUrl}/user-response-rate`, {
+					method: "GET",
+					credentials: "include" // Include cookies for session management
+				});
+
+				if (!response.ok) {
+					if (response.status === 404) {
+						// No data found
+						console.warn("No data found");
+						return;
+					} else {
+						throw new Error(`HTTP error! status: ${response.status}`);
+					}
+				}
+				const result = await response.json();
+				// eslint-disable-next-line no-console
+				console.log(result.value);
+
+				if (result.length === 0) {
+					// No data found
+					console.warn("Empty response");
path: frontend/components/response_rate_card.tsx
user: login: lnovitz
body: If no data is found, what does the user see on the dashboard? 
created_at: 2025-03-30T16:43:59Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,97 @@
+"use client";
+import { useState, useEffect } from "react";
+import { useRouter } from "next/navigation";
+
+import { ClockIcon } from "@/components/icons";
+
+export default function ResponseRateCard() {
+	const [lastUpdated, setLastUpdated] = useState<string>("");
+
+	const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/";
+	const [value, setValue] = useState(null);
+	const router = useRouter();
+
+	useEffect(() => {
+		const fetchData = async () => {
+			try {
+				const response = await fetch(`${apiUrl}/user-response-rate`, {
+					method: "GET",
+					credentials: "include" // Include cookies for session management
+				});
+
+				if (!response.ok) {
+					if (response.status === 404) {
+						// No data found
+						console.warn("No data found");
+						return;
+					} else {
+						throw new Error(`HTTP error! status: ${response.status}`);
+					}
+				}
+				const result = await response.json();
+				// eslint-disable-next-line no-console
+				console.log(result.value);
+
+				if (result.length === 0) {
+					// No data found
+					console.warn("Empty response");
path: frontend/components/response_rate_card.tsx
user: login: lnovitz
body: "Sorry, we're having trouble loading the response rate right now. Please click here to send a bug report to jobba.help. Thanks!" If you can use the dynamic error message text seen [here](https://github.com/lnovitz/jobseeker-analytics/blob/main/frontend/app/errors/page.tsx#L26), that'd be great. 
created_at: 2025-03-30T16:49:14Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,97 @@
+"use client";
+import { useState, useEffect } from "react";
+import { useRouter } from "next/navigation";
+
+import { ClockIcon } from "@/components/icons";
+
+export default function ResponseRateCard() {
+	const [lastUpdated, setLastUpdated] = useState<string>("");
+
+	const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/";
+	const [value, setValue] = useState(null);
+	const router = useRouter();
+
+	useEffect(() => {
+		const fetchData = async () => {
+			try {
+				const response = await fetch(`${apiUrl}/user-response-rate`, {
+					method: "GET",
+					credentials: "include" // Include cookies for session management
+				});
+
+				if (!response.ok) {
+					if (response.status === 404) {
+						// No data found
+						console.warn("No data found");
+						return;
+					} else {
+						throw new Error(`HTTP error! status: ${response.status}`);
+					}
+				}
+				const result = await response.json();
+				// eslint-disable-next-line no-console
+				console.log(result.value);
+
+				if (result.length === 0) {
+					// No data found
+					console.warn("Empty response");
path: frontend/components/response_rate_card.tsx
user: login: jediela
body: ![image](https://github.com/user-attachments/assets/95652a58-298b-4cab-8ce6-aa1ff2cc61dc)

created_at: 2025-03-30T20:35:46Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,97 @@
+"use client";
+import { useState, useEffect } from "react";
+import { useRouter } from "next/navigation";
+
+import { ClockIcon } from "@/components/icons";
+
+export default function ResponseRateCard() {
+	const [lastUpdated, setLastUpdated] = useState<string>("");
+
+	const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/";
+	const [value, setValue] = useState(null);
+	const router = useRouter();
+
+	useEffect(() => {
+		const fetchData = async () => {
+			try {
+				const response = await fetch(`${apiUrl}/user-response-rate`, {
+					method: "GET",
+					credentials: "include" // Include cookies for session management
+				});
+
+				if (!response.ok) {
+					if (response.status === 404) {
+						// No data found
+						console.warn("No data found");
+						return;
+					} else {
+						throw new Error(`HTTP error! status: ${response.status}`);
+					}
+				}
+				const result = await response.json();
+				// eslint-disable-next-line no-console
+				console.log(result.value);
+
+				if (result.length === 0) {
+					// No data found
+					console.warn("Empty response");
path: frontend/components/response_rate_card.tsx
user: login: jediela
body: changed it to:
```
if (!response.ok) {
	addToast({
		title: "An error occurred while loading the response rate",
		description: "Please try again or contact help@jobba.help if the issue persists.",
		color: "danger"
	});
	return;
}
```
since the API does not have a length object
created_at: 2025-03-30T20:36:57Z
author_association: COLLABORATOR
diff_hunk: @@ -18,6 +18,64 @@
 # FastAPI router for email routes
 router = APIRouter()
 
+@router.get("/get-response-rate")       
+def response_rate_by_job_title(user_id: str = Depends(validate_session)):
path: backend/routes/users_routes.py
user: login: lnovitz
body: Same feedback I gave Alice - rate limiting needed for any new endpoints. Also you'll need the `request` param. https://github.com/lnovitz/jobseeker-analytics/pull/248/files#r2020237726
created_at: 2025-03-30T22:31:44Z
author_association: COLLABORATOR

PR comments:
user: login: lnovitz
created_at: 2025-03-26T01:15:20Z
body: @elyseando if you can help put on one page that'd be perf
author_association: COLLABORATOR
user: login: lnovitz
created_at: 2025-03-26T01:18:37Z
body: Consider mobile view
author_association: COLLABORATOR
user: login: elyseando
created_at: 2025-03-27T01:14:49Z
body: <img width="1920" alt="Screenshot 2025-03-26 at 9 14 06 PM" src="https://github.com/user-attachments/assets/5b0679c5-b903-4d43-9c7e-a2ce6bf1699e" />

author_association: COLLABORATOR
user: login: lnovitz
created_at: 2025-03-27T01:39:02Z
body: Yasssss
author_association: COLLABORATOR
user: login: elyseando
created_at: 2025-03-28T00:21:24Z
body: I'm going to add the response rate chart to the dashboard. 
author_association: COLLABORATOR
user: login: elyseando
created_at: 2025-03-29T22:09:40Z
body: <img width="1440" alt="Screenshot 2025-03-29 at 6 08 34 PM" src="https://github.com/user-attachments/assets/2c85cc58-aaf1-4e50-a6ac-63a6fa200c69" />
<img width="1440" alt="Screenshot 2025-03-29 at 6 08 47 PM" src="https://github.com/user-attachments/assets/14fdbdc3-c544-46f6-990d-a60f5c3ad327" />
<img width="224" alt="Screenshot 2025-03-29 at 6 09 03 PM" src="https://github.com/user-attachments/assets/198aeaee-4e38-4545-93eb-da982cc218c5" />

author_association: COLLABORATOR
user: login: lnovitz
created_at: 2025-03-31T03:35:34Z
body: Amazing!
author_association: COLLABORATOR