From 631cac2dc320c8bb616e785a48a1e3cdab906557 Mon Sep 17 00:00:00 2001 From: bagas Date: Sun, 22 Feb 2026 19:03:35 +0700 Subject: [PATCH] feat: add view responses page --- app/routes.ts | 1 + app/routes/form-responses.tsx | 651 ++++++++++++++++++++++++++++++++++ app/routes/form.tsx | 7 + app/routes/forms.tsx | 4 +- app/routes/submit-form.tsx | 6 - lib/api.ts | 15 +- lib/types.ts | 13 + package-lock.json | 389 ++++++++++++++++++++ package.json | 1 + 9 files changed, 1077 insertions(+), 10 deletions(-) create mode 100644 app/routes/form-responses.tsx diff --git a/app/routes.ts b/app/routes.ts index e6e8c85..075aa87 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -8,6 +8,7 @@ export default [ route("forms/new", "routes/create-form.tsx"), route("form/:id", "routes/form.tsx"), route("form/:id/submit", "routes/submit-form.tsx"), + route("form/:id/responses", "routes/form-responses.tsx"), route("form/:id/edit", "routes/edit-form.tsx"), route("*", "routes/not-found.tsx"), ] satisfies RouteConfig; diff --git a/app/routes/form-responses.tsx b/app/routes/form-responses.tsx new file mode 100644 index 0000000..fcc9827 --- /dev/null +++ b/app/routes/form-responses.tsx @@ -0,0 +1,651 @@ +import { useState, useEffect, useMemo } from "react" +import { Link, useParams, useNavigate } from "react-router" +import { + ArrowLeft, + BarChart3, + Calendar, + Clock, + FileText, + MessageSquare, + Users, +} from "lucide-react" +import { + PieChart, + Pie, + Cell, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts" +import { Navbar } from "@/components/shared/navbar" +import { Footer } from "@/components/shared/footer" +import { FormButton } from "@/components/shared/form-button" +import { useAuth } from "@/app/context/auth-context" +import { getFormById, getFormResponses } from "@/lib/api" +import type { FormDetail, FormResponse, Question, QuestionType } from "@/lib/types" + +const CHART_COLORS = [ + "hsl(221, 83%, 53%)", + "hsl(142, 71%, 45%)", + "hsl(38, 92%, 50%)", + "hsl(0, 84%, 60%)", + "hsl(262, 83%, 58%)", + "hsl(172, 66%, 50%)", + "hsl(326, 80%, 55%)", + "hsl(25, 95%, 53%)", + "hsl(199, 89%, 48%)", + "hsl(47, 96%, 53%)", +] + +type TabType = "summary" | "individual" + +export default function FormResponsesPage() { + const { id } = useParams() + const navigate = useNavigate() + const { user, loading: authLoading } = useAuth() + const [form, setForm] = useState(null) + const [responses, setResponses] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [activeTab, setActiveTab] = useState("summary") + + useEffect(() => { + if (!authLoading && !user) { + navigate("/login") + } + }, [user, authLoading, navigate]) + + useEffect(() => { + if (!id || !user) return + + async function fetchData() { + try { + const [formData, responsesData] = await Promise.all([ + getFormById(id!), + getFormResponses(id!), + ]) + setForm(formData) + setResponses(responsesData) + } catch (err) { + if (err instanceof Response && err.status === 404) { + setError("Form not found") + } else { + setError("Failed to load responses. Please try again.") + } + } finally { + setLoading(false) + } + } + fetchData() + }, [id, user]) + + if (authLoading || loading) { + return ( +
+

Loading responses...

+
+ ) + } + + if (error || !form) { + return ( +
+ +
+
+

{error ?? "Form not found"}

+ + + Back to forms + +
+
+
+
+ ) + } + + if (!user) return null + + return ( +
+ + +
+
+ + + Back to form + + + {/* Header */} +
+
+
+
+
+

+ {form.title} +

+

+ {form.description} +

+
+
+ + + + Preview + + +
+
+ + {/* Stats */} +
+ } + label="Responses" + value={responses.length} + /> + } + label="Questions" + value={form.questions.length} + /> + } + label="Created" + value={new Date(form.created_at).toLocaleDateString()} + /> + } + label="Last response" + value={ + responses.length > 0 + ? new Date(responses[0].submitted_at).toLocaleDateString() + : "—" + } + /> +
+
+
+ + {/* Tabs */} +
+ + +
+ + {responses.length === 0 ? ( +
+ +

+ No responses yet. Share this form to start collecting data. +

+ + + View submission page + + +
+ ) : activeTab === "summary" ? ( + + ) : ( + + )} +
+
+ +
+
+ ) +} + +function StatCard({ + icon, + label, + value, +}: { + icon: React.ReactNode + label: string + value: string | number +}) { + return ( +
+
+ {icon} + {label} +
+

{value}

+
+ ) +} + +function SummaryTab({ + form, + responses, +}: { + form: FormDetail + responses: FormResponse[] +}) { + return ( +
+ {form.questions.map((question, index) => ( + + ))} +
+ ) +} + +function QuestionSummary({ + question, + index, + responses, + totalResponses, +}: { + question: Question + index: number + responses: FormResponse[] + totalResponses: number +}) { + const answersForQuestion = useMemo(() => { + return responses + .map((r) => r.answers.find((a) => a.question_id === question.id)) + .filter(Boolean) + }, [responses, question.id]) + + const responseCount = answersForQuestion.length + const skipped = totalResponses - responseCount + + return ( +
+
+
+ + {index + 1} + +
+

+ {question.title} + {question.required && ( + * + )} +

+
+ {question.type.replace("_", " ")} + · + {responseCount} answers + {skipped > 0 && ( + <> + · + {skipped} skipped + + )} +
+
+
+
+ +
+ {hasChartableType(question.type) ? ( + + ) : ( + + )} +
+
+ ) +} + +function hasChartableType(type: QuestionType): boolean { + return ["multiple_choice", "checkbox", "dropdown", "rating"].includes(type) +} + +function ChartVisualization({ + question, + responses, +}: { + question: Question + responses: FormResponse[] +}) { + const chartData = useMemo(() => { + const counts: Record = {} + + if (["multiple_choice", "dropdown"].includes(question.type)) { + question.options.forEach((opt) => { + counts[opt.label] = 0 + }) + } + + if (question.type === "checkbox") { + question.options.forEach((opt) => { + counts[opt.label] = 0 + }) + } + + if (question.type === "rating") { + for (let i = 1; i <= 5; i++) { + counts[String(i)] = 0 + } + } + + responses.forEach((r) => { + const answer = r.answers.find((a) => a.question_id === question.id) + if (!answer) return + + if (question.type === "checkbox") { + const selected = answer.answer.split(",").map((s) => s.trim()) + selected.forEach((s) => { + counts[s] = (counts[s] || 0) + 1 + }) + } else { + counts[answer.answer] = (counts[answer.answer] || 0) + 1 + } + }) + + return Object.entries(counts).map(([name, value]) => ({ + name, + value, + })) + }, [question, responses]) + + const total = chartData.reduce((sum, d) => sum + d.value, 0) + + if (question.type === "rating") { + const avgRating = useMemo(() => { + let sum = 0 + let count = 0 + responses.forEach((r) => { + const answer = r.answers.find((a) => a.question_id === question.id) + if (answer) { + sum += Number(answer.answer) + count++ + } + }) + return count > 0 ? (sum / count).toFixed(1) : "—" + }, [responses, question.id]) + + return ( +
+
+ {avgRating} + average rating out of 5 +
+ + + + `${v} ★`} + className="fill-muted-foreground" + /> + + + + {chartData.map((_, i) => ( + + ))} + + + +
+ ) + } + + return ( +
+
+ + + + {chartData.map((_, i) => ( + + ))} + + [ + `${value ?? 0} (${total > 0 ? (((value ?? 0) / total) * 100).toFixed(0) : 0}%)`, + "Responses", + ]} + /> + + +
+ +
+ {chartData.map((item, i) => ( +
+ + {item.name} + {item.value} + + {total > 0 ? ((item.value / total) * 100).toFixed(0) : 0}% + +
+ ))} +
+
+ ) +} + +function TextResponses({ + questionId, + responses, +}: { + questionId: string + responses: FormResponse[] +}) { + const [showAll, setShowAll] = useState(false) + const textAnswers = useMemo(() => { + return responses + .map((r) => { + const answer = r.answers.find((a) => a.question_id === questionId) + return answer + ? { text: answer.answer, date: r.submitted_at } + : null + }) + .filter(Boolean) as { text: string; date: string }[] + }, [responses, questionId]) + + if (textAnswers.length === 0) { + return ( +

No responses for this question.

+ ) + } + + const visible = showAll ? textAnswers : textAnswers.slice(0, 5) + + return ( +
+
+ {visible.map((item, i) => ( +
+

{item.text}

+

+ {new Date(item.date).toLocaleString()} +

+
+ ))} +
+ {textAnswers.length > 5 && ( + + )} +
+ ) +} + +function IndividualTab({ + form, + responses, +}: { + form: FormDetail + responses: FormResponse[] +}) { + const [selectedIndex, setSelectedIndex] = useState(0) + + const sorted = useMemo( + () => + [...responses].sort( + (a, b) => + new Date(b.submitted_at).getTime() - new Date(a.submitted_at).getTime() + ), + [responses] + ) + + const current = sorted[selectedIndex] + if (!current) return null + + return ( +
+ {/* Navigation */} +
+ +
+

+ Response {selectedIndex + 1} of {sorted.length} +

+

+ {new Date(current.submitted_at).toLocaleString()} +

+
+ +
+ + {/* Answers */} +
+ {form.questions.map((question, index) => { + const answer = current.answers.find( + (a) => a.question_id === question.id + ) + return ( +
+
+ + {index + 1} + +
+

+ {question.title} + {question.required && ( + * + )} +

+

+ {question.type.replace("_", " ")} +

+ {answer ? ( +
+

{answer.answer}

+
+ ) : ( +

+ No answer (skipped) +

+ )} +
+
+
+ ) + })} +
+
+ ) +} diff --git a/app/routes/form.tsx b/app/routes/form.tsx index 48b8058..d61a8a7 100644 --- a/app/routes/form.tsx +++ b/app/routes/form.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react" import { Link, useParams, useNavigate } from "react-router" import { ArrowLeft, + BarChart3, Calendar, FileText, Lock, @@ -142,6 +143,12 @@ export default function FormPreviewPage() { {user && user.id === form.user_id && (
+ + + + Responses + + diff --git a/app/routes/forms.tsx b/app/routes/forms.tsx index 1790883..0365733 100644 --- a/app/routes/forms.tsx +++ b/app/routes/forms.tsx @@ -63,13 +63,11 @@ export default function FormsPage() { } }, []) - // Initial fetch useEffect(() => { if (!user) return fetchForms({ search, statusFilter, sortBy, sortDir }) - }, [user]) // eslint-disable-line react-hooks/exhaustive-deps + }, [user]) - // Debounced fetch when filters change (skip initial) useEffect(() => { if (!user || isInitialLoad.current) return diff --git a/app/routes/submit-form.tsx b/app/routes/submit-form.tsx index 6d8f026..e47dc13 100644 --- a/app/routes/submit-form.tsx +++ b/app/routes/submit-form.tsx @@ -32,7 +32,6 @@ export default function SubmitFormPage() { try { const data = await getFormById(id!) setForm(data) - // Initialize empty answers const initial: Record = {} data.questions.forEach((q) => { initial[q.id] = "" @@ -53,7 +52,6 @@ export default function SubmitFormPage() { function updateAnswer(questionId: string, value: string) { setAnswers((prev) => ({ ...prev, [questionId]: value })) - // Clear validation error on change if (validationErrors[questionId]) { setValidationErrors((prev) => { const next = { ...prev } @@ -82,7 +80,6 @@ export default function SubmitFormPage() { if (!id || !form) return if (!validate()) { - // Scroll to first error const firstErrorId = form.questions.find((q) => validationErrors[q.id] || (q.required && !answers[q.id]?.trim()))?.id if (firstErrorId) { document.getElementById(`question-${firstErrorId}`)?.scrollIntoView({ behavior: "smooth", block: "center" }) @@ -161,7 +158,6 @@ export default function SubmitFormPage() { size="sm" onClick={() => { setSubmitted(false) - // Reset answers if (form) { const initial: Record = {} form.questions.forEach((q) => { initial[q.id] = "" }) @@ -270,8 +266,6 @@ export default function SubmitFormPage() { ) } -// ─── Question field component ──────────────────────────────────────────────── - interface QuestionFieldProps { question: Question index: number diff --git a/lib/api.ts b/lib/api.ts index 2f2368e..5ab57a1 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -6,7 +6,7 @@ import { isTokenExpired, decodeJWT, } from "@/lib/auth" -import type { FormSummary, FormDetail, CreateFormPayload, UpdateFormPayload, SubmitFormPayload } from "@/lib/types" +import type { FormSummary, FormDetail, CreateFormPayload, UpdateFormPayload, SubmitFormPayload, FormResponse } from "@/lib/types" export async function refreshAccessToken(): Promise { if (typeof window === "undefined") return null @@ -232,4 +232,17 @@ export async function deleteForm(id: string): Promise { const data = await res.json().catch(() => null) throw new Error(data?.message ?? "Failed to delete form") } +} + +export async function getFormResponses(id: string): Promise { + const res = await fetchWithAuth(`${import.meta.env.VITE_API_BASE_URL}/api/form/${id}/responses`) + + if (!res.ok) { + if (res.status === 404) { + throw new Response("Form not found", { status: 404 }) + } + throw new Error("Failed to fetch form responses") + } + + return res.json() } \ No newline at end of file diff --git a/lib/types.ts b/lib/types.ts index ee6e7b1..2212a82 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -75,3 +75,16 @@ export interface SubmitFormAnswer { export interface SubmitFormPayload { answers: SubmitFormAnswer[] } + +export interface FormResponseAnswer { + question_id: string + question_title: string + question_type: QuestionType + answer: string +} + +export interface FormResponse { + id: string + submitted_at: string + answers: FormResponseAnswer[] +} diff --git a/package-lock.json b/package-lock.json index 80de91a..3ec746d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-router": "7.12.0", + "recharts": "^3.7.0", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0" }, @@ -2665,6 +2666,42 @@ "react-router": "7.12.0" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@remix-run/node-fetch-server": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@remix-run/node-fetch-server/-/node-fetch-server-0.9.0.tgz", @@ -3022,6 +3059,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.0.tgz", @@ -3294,6 +3343,69 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "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==", + "license": "MIT" + }, + "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==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3331,6 +3443,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -3709,6 +3827,127 @@ "devOptional": true, "license": "MIT" }, + "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==", + "license": "ISC", + "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==", + "license": "ISC", + "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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "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==", + "license": "ISC", + "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==", + "license": "ISC", + "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==", + "license": "ISC", + "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==", + "license": "ISC", + "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==", + "license": "ISC", + "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==", + "license": "ISC", + "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==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3727,6 +3966,12 @@ } } }, + "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==", + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", @@ -3864,6 +4109,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -3931,6 +4186,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/exit-hook": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", @@ -4255,12 +4516,31 @@ "node": ">=0.10.0" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "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==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -5083,6 +5363,36 @@ "react": "^19.2.4" } }, + "node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -5211,6 +5521,57 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/recharts": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", + "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.58.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.58.0.tgz", @@ -5508,6 +5869,12 @@ "url": "https://opencollective.com/webpack" } }, + "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==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -5729,6 +6096,28 @@ "node": ">= 0.8" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "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/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/package.json b/package.json index b9d507b..da70fce 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-router": "7.12.0", + "recharts": "^3.7.0", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0" },