From 749b9e24faa405e3cfd27f1b7514ca96d0a5a134 Mon Sep 17 00:00:00 2001 From: ItsEricSun Date: Wed, 18 Feb 2026 19:40:03 -0500 Subject: [PATCH 1/8] add donor chart --- apps/frontend/src/api/apiClient.ts | 31 ++ apps/frontend/src/app.tsx | 11 + .../src/components/DonorStatsChart.tsx | 395 ++++++++++++++++++ apps/frontend/src/components/ui/chart.tsx | 369 ++++++++++++++++ .../src/components/ui/dropdown-menu.tsx | 273 ++++++++++++ package.json | 4 + yarn.lock | 263 +++++++++++- 7 files changed, 1343 insertions(+), 3 deletions(-) create mode 100644 apps/frontend/src/components/DonorStatsChart.tsx create mode 100644 apps/frontend/src/components/ui/chart.tsx create mode 100644 apps/frontend/src/components/ui/dropdown-menu.tsx diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 9cee8a4..d4e51b0 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -111,6 +111,37 @@ export class ApiClient { } } + public async getDonations(params?: { + page?: number; + perPage?: number; + donationType?: 'one_time' | 'recurring'; + status?: 'pending' | 'succeeded' | 'failed' | 'cancelled'; + }): Promise<{ + rows: Array<{ + id: number; + firstName: string; + lastName: string; + email: string; + amount: number; + donationType: 'one_time' | 'recurring'; + status: string; + createdAt: string; + }>; + total: number; + page: number; + perPage: number; + totalPages: number; + }> { + try { + const res = await this.axiosInstance.get('/api/donations', { + params, + }); + return res.data; + } catch (err: unknown) { + this.handleAxiosError(err, 'Failed to fetch donations'); + } + } + private async get(path: string): Promise { return this.axiosInstance.get(path).then((response) => response.data); } diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index 7bfb3c2..a0964de 100644 --- a/apps/frontend/src/app.tsx +++ b/apps/frontend/src/app.tsx @@ -11,6 +11,7 @@ import { AuthProvider } from '@components/AuthProvider'; import { ProtectedRoute } from '@components/ProtectedRoute'; import { LoginPage } from '@containers/auth/LoginPage'; import { DashboardPage } from '@containers/dashboard/DashboardPage'; +import { DonorStatsChart } from '@components/DonorStatsChart'; const router = createBrowserRouter([ { @@ -40,6 +41,16 @@ const router = createBrowserRouter([ path: '/shadcn-example', element: , }, + { + path: '/chart', + element: , + children: [ + { + path: '', + element: , + }, + ], + }, { path: '/donate', element: ( diff --git a/apps/frontend/src/components/DonorStatsChart.tsx b/apps/frontend/src/components/DonorStatsChart.tsx new file mode 100644 index 0000000..00a9d31 --- /dev/null +++ b/apps/frontend/src/components/DonorStatsChart.tsx @@ -0,0 +1,395 @@ +'use client'; + +import * as React from 'react'; +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@components/ui/card'; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from '@components/ui/chart'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@components/ui/dropdown-menu'; +import { Input } from '@components/ui/input'; +import { Label } from '@components/ui/label'; +import { ChevronDownIcon } from 'lucide-react'; +import apiClient from '@api/apiClient'; + +const chartConfig = { + donations: { + label: 'Total Donations', + color: 'hsl(160, 60%, 45%)', + }, + recurring_donors: { + label: 'Recurring Donors', + color: 'hsl(220, 70%, 50%)', + }, +} satisfies ChartConfig; + +type DataType = 'donations' | 'recurring_donors'; +type TimeUnit = 'weeks' | 'months' | 'years'; + +// Helper function to calculate date range +function getStartDate(quantity: number, unit: TimeUnit): Date { + const now = new Date(); + const start = new Date(now); + + switch (unit) { + case 'weeks': + start.setDate(now.getDate() - quantity * 7); + break; + case 'months': + start.setMonth(now.getMonth() - quantity); + break; + case 'years': + start.setFullYear(now.getFullYear() - quantity); + break; + } + + return start; +} + +// Helper function to format date as YYYY-MM-DD in local time +function formatDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +// Parse backend timestamps as UTC when no timezone is provided. +function parseBackendDate(value: string): Date { + const hasTimezone = /Z|[+-]\d{2}:?\d{2}$/.test(value); + return new Date(hasTimezone ? value : `${value}Z`); +} + +// Parse YYYY-MM-DD date keys into local Date objects. +function parseDateKey(value: string): Date { + const [year, month, day] = value.split('-').map(Number); + return new Date(year, (month || 1) - 1, day || 1); +} + +// Process donations data into time-series +function processDonationsData( + donations: Array<{ amount: number; createdAt: string; status: string }>, + startDate: Date, +): Array<{ date: string; value: number }> { + const dataMap = new Map(); + + donations.forEach((donation) => { + const donationDate = parseBackendDate(donation.createdAt); + if (donationDate >= startDate && donation.status === 'succeeded') { + const dateKey = formatDate(donationDate); + dataMap.set(dateKey, (dataMap.get(dateKey) || 0) + donation.amount); + } + }); + + return Array.from(dataMap.entries()) + .map(([date, value]) => ({ date, value })) + .sort((a, b) => a.date.localeCompare(b.date)); +} + +// Process recurring donors data (first recurring donation per email) +function processRecurringDonorsData( + donations: Array<{ + email: string; + createdAt: string; + donationType: string; + status: string; + }>, + startDate: Date, +): Array<{ date: string; value: number }> { + // Find first recurring donation per email + const firstRecurringByEmail = new Map(); + + donations.forEach((donation) => { + if ( + donation.donationType === 'recurring' && + donation.status === 'succeeded' + ) { + const donationDate = parseBackendDate(donation.createdAt); + const existing = firstRecurringByEmail.get(donation.email); + + if (!existing || donationDate < existing) { + firstRecurringByEmail.set(donation.email, donationDate); + } + } + }); + + // Group by date + const dataMap = new Map(); + + firstRecurringByEmail.forEach((firstDate) => { + if (firstDate >= startDate) { + const dateKey = formatDate(firstDate); + dataMap.set(dateKey, (dataMap.get(dateKey) || 0) + 1); + } + }); + + return Array.from(dataMap.entries()) + .map(([date, value]) => ({ date, value })) + .sort((a, b) => a.date.localeCompare(b.date)); +} + +export function DonorStatsChart() { + const [activeChart, setActiveChart] = React.useState('donations'); + const [quantity, setQuantity] = React.useState(6); + const [unit, setUnit] = React.useState('months'); + const [chartData, setChartData] = React.useState< + Array<{ date: string; value: number }> + >([]); + const [donationsData, setDonationsData] = React.useState< + Array<{ date: string; value: number }> + >([]); + const [recurringDonorsData, setRecurringDonorsData] = React.useState< + Array<{ date: string; value: number }> + >([]); + const [isLoading, setIsLoading] = React.useState(false); + const [error, setError] = React.useState(null); + + // Fetch data whenever activeChart, quantity, or unit changes + React.useEffect(() => { + const fetchData = async () => { + setIsLoading(true); + setError(null); + try { + // Fetch donations with a large perPage to get all we need + const response = await apiClient.getDonations({ + perPage: 10000, + status: 'succeeded', + }); + + const startDate = getStartDate(quantity, unit); + const donationsProcessed = processDonationsData( + response.rows, + startDate, + ); + const recurringDonorsProcessed = processRecurringDonorsData( + response.rows, + startDate, + ); + + setDonationsData(donationsProcessed); + setRecurringDonorsData(recurringDonorsProcessed); + + if (activeChart === 'donations') { + setChartData(donationsProcessed); + } else { + setChartData(recurringDonorsProcessed); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load data'); + setChartData([]); + } finally { + setIsLoading(false); + } + }; + + fetchData(); + }, [activeChart, quantity, unit]); + + // Update chart data when activeChart changes + React.useEffect(() => { + if (activeChart === 'donations') { + setChartData(donationsData); + } else { + setChartData(recurringDonorsData); + } + }, [activeChart, donationsData, recurringDonorsData]); + + // Calculate totals for display + const donationsTotal = React.useMemo(() => { + return donationsData.reduce((acc, curr) => acc + curr.value, 0); + }, [donationsData]); + + const recurringDonorsTotal = React.useMemo(() => { + return recurringDonorsData.reduce((acc, curr) => acc + curr.value, 0); + }, [recurringDonorsData]); + + const handleQuantityChange = (e: React.ChangeEvent) => { + const value = parseInt(e.target.value); + if (value >= 1 && value <= 12) { + setQuantity(value); + } + }; + + return ( + + +
+ Donor Statistics + + {activeChart === 'donations' + ? 'Total donation amounts over time' + : 'New recurring donors over time'} + +
+
+ {(['donations', 'recurring_donors'] as const).map((key) => { + const chart = key as DataType; + return ( + + ); + })} +
+
+ + + + + { + const date = parseDateKey(value); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + }} + /> + { + if (activeChart === 'donations') { + // Format as currency (e.g., $50) + return `$${(value / 100).toLocaleString()}`; + } else { + // Format as count (e.g., 4) + return value.toString(); + } + }} + /> + { + return parseDateKey(value).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + }} + formatter={(value: any) => { + if (activeChart === 'donations') { + // Format as currency with cents + return `$${(value / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + } else { + // Format as count + return value.toLocaleString(); + } + }} + /> + } + /> + + + + + {error && ( +
+ {error} +
+ )} + +
+
+ +
+ + + + + + + setUnit('weeks')}> + Weeks + + setUnit('months')}> + Months + + setUnit('years')}> + Years + + + +
+
+
+ Showing last {quantity} {unit} +
+
+
+
+ ); +} diff --git a/apps/frontend/src/components/ui/chart.tsx b/apps/frontend/src/components/ui/chart.tsx new file mode 100644 index 0000000..2abf4f9 --- /dev/null +++ b/apps/frontend/src/components/ui/chart.tsx @@ -0,0 +1,369 @@ +'use client'; + +import * as React from 'react'; +import * as RechartsPrimitive from 'recharts'; + +import { cn } from '@lib/utils'; + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: '', dark: '.dark' } as const; + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ); +}; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error('useChart must be used within a '); + } + + return context; +} + +function ChartContainer({ + id, + className, + children, + config, + ...props +}: React.ComponentProps<'div'> & { + config: ChartConfig; + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >['children']; +}) { + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`; + + return ( + +
+ + + {children} + +
+
+ ); +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme || config.color, + ); + + if (!colorConfig.length) { + return null; + } + + return ( +