From f2b4b7525393f1c15f7ca10cfc5010cfab08fcc4 Mon Sep 17 00:00:00 2001 From: El-Chapo-Npm <2.73218934e+08+El-Chapo-Npm@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:43:39 +0000 Subject: [PATCH 1/2] feat: add ROI and performance metrics hooks --- hooks/usePerformanceMetrics.ts | 82 ++++++++++++++++++++++++++++++++++ hooks/useRoiMetrics.ts | 72 +++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 hooks/usePerformanceMetrics.ts create mode 100644 hooks/useRoiMetrics.ts diff --git a/hooks/usePerformanceMetrics.ts b/hooks/usePerformanceMetrics.ts new file mode 100644 index 0000000..7b2306c --- /dev/null +++ b/hooks/usePerformanceMetrics.ts @@ -0,0 +1,82 @@ +'use client'; + +import { useQuery, type UseQueryResult } from '@tanstack/react-query'; +import api from '@/lib/api'; + +export interface DeliveryTimeMetric { + date: string; + averageMinutes: number; + targetMinutes?: number; +} + +export interface CompletionRateMetric { + date: string; + completed: number; + cancelled: number; + rate: number; +} + +export interface RegionalVolumeMetric { + region: string; + volume: number; + percentage: number; +} + +export interface DriverPerformanceRanking { + driverId: string; + driverName: string; + deliveries: number; + completionRate: number; + averageDeliveryMinutes: number; + score: number; +} + +export interface PerformanceMetrics { + totalDeliveries: number; + completionRate: number; + averageDeliveryMinutes: number; + deliveryTimes: DeliveryTimeMetric[]; + completionRates: CompletionRateMetric[]; + regionalVolume: RegionalVolumeMetric[]; + driverRankings: DriverPerformanceRanking[]; +} + +export interface PerformanceMetricsParams { + startDate?: string; + endDate?: string; + region?: string; + limit?: number; +} + +export const PERFORMANCE_METRICS_QUERY_KEY = ['analytics', 'performance'] as const; + +function unwrapResponse(payload: T | { data?: T }): T { + if (payload && typeof payload === 'object' && 'data' in payload && payload.data !== undefined) { + return payload.data as T; + } + return payload as T; +} + +export interface UsePerformanceMetricsResult + extends Omit, 'data'> { + metrics: PerformanceMetrics | null; +} + +export function usePerformanceMetrics( + params: PerformanceMetricsParams = {}, +): UsePerformanceMetricsResult { + const query = useQuery({ + queryKey: [...PERFORMANCE_METRICS_QUERY_KEY, params], + queryFn: async () => { + const response = await api.get( + '/analytics/performance', + { params }, + ); + return unwrapResponse(response.data); + }, + staleTime: 60_000, + retry: 1, + }); + + return { ...query, metrics: query.data ?? null }; +} diff --git a/hooks/useRoiMetrics.ts b/hooks/useRoiMetrics.ts new file mode 100644 index 0000000..4364180 --- /dev/null +++ b/hooks/useRoiMetrics.ts @@ -0,0 +1,72 @@ +'use client'; + +import { useQuery, type UseQueryResult } from '@tanstack/react-query'; +import api from '@/lib/api'; + +export interface RoiTrendPoint { + date: string; + revenue: number; + cost: number; + roi: number; +} + +export interface RoiCostBreakdownItem { + category: string; + amount: number; + percentage: number; +} + +export interface DriverProfitability { + driverId: string; + driverName: string; + revenue: number; + cost: number; + profit: number; + roi: number; +} + +export interface RoiMetrics { + totalRevenue: number; + totalCost: number; + totalProfit: number; + roi: number; + returnTrends: RoiTrendPoint[]; + costBreakdown: RoiCostBreakdownItem[]; + driverProfitability: DriverProfitability[]; +} + +export interface RoiMetricsParams { + startDate?: string; + endDate?: string; + region?: string; + driverId?: string; +} + +export const ROI_METRICS_QUERY_KEY = ['analytics', 'roi'] as const; + +function unwrapResponse(payload: T | { data?: T }): T { + if (payload && typeof payload === 'object' && 'data' in payload && payload.data !== undefined) { + return payload.data as T; + } + return payload as T; +} + +export interface UseRoiMetricsResult extends Omit, 'data'> { + metrics: RoiMetrics | null; +} + +export function useRoiMetrics(params: RoiMetricsParams = {}): UseRoiMetricsResult { + const query = useQuery({ + queryKey: [...ROI_METRICS_QUERY_KEY, params], + queryFn: async () => { + const response = await api.get('/analytics/roi', { + params, + }); + return unwrapResponse(response.data); + }, + staleTime: 60_000, + retry: 1, + }); + + return { ...query, metrics: query.data ?? null }; +} From 0bd5ec2542655b8b2fe5bc89d1eb314d89d2b769 Mon Sep 17 00:00:00 2001 From: El-Chapo-Npm <2.73218934e+08+El-Chapo-Npm@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:45:26 +0000 Subject: [PATCH 2/2] test: add ROI and performance dashboard coverage --- .../PerformanceMetricsDashboard.test.tsx | 58 ++++++++++++ .../dashboard/RoiDashboard.test.tsx | 64 +++++++++++++ .../dashboard/PerformanceMetricsDashboard.tsx | 73 +++++++++++++++ components/dashboard/RoiDashboard.tsx | 89 +++++++++++++++++++ 4 files changed, 284 insertions(+) create mode 100644 __tests__/components/dashboard/PerformanceMetricsDashboard.test.tsx create mode 100644 __tests__/components/dashboard/RoiDashboard.test.tsx create mode 100644 components/dashboard/PerformanceMetricsDashboard.tsx create mode 100644 components/dashboard/RoiDashboard.tsx diff --git a/__tests__/components/dashboard/PerformanceMetricsDashboard.test.tsx b/__tests__/components/dashboard/PerformanceMetricsDashboard.test.tsx new file mode 100644 index 0000000..a86043c --- /dev/null +++ b/__tests__/components/dashboard/PerformanceMetricsDashboard.test.tsx @@ -0,0 +1,58 @@ +import { render, screen, fireEvent, waitFor, renderHook } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { PerformanceMetricsDashboard } from '@/components/dashboard/PerformanceMetricsDashboard'; +import { usePerformanceMetrics } from '@/hooks/usePerformanceMetrics'; +import api from '@/lib/api'; + +jest.mock('@/lib/api', () => ({ __esModule: true, default: { get: jest.fn() } })); + +const mockApi = api as jest.Mocked; +const metrics = { + totalDeliveries: 1250, + completionRate: 96.4, + averageDeliveryMinutes: 42, + deliveryTimes: [{ date: '2026-01', averageMinutes: 42 }], + completionRates: [{ date: '2026-01', completed: 1205, cancelled: 45, rate: 96.4 }], + regionalVolume: [{ region: 'Lagos', volume: 700, percentage: 56 }], + driverRankings: [{ driverId: 'd1', driverName: 'Bola', deliveries: 120, completionRate: 98, averageDeliveryMinutes: 38, score: 97.5 }], +}; + +function wrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +describe('usePerformanceMetrics and PerformanceMetricsDashboard', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockApi.get.mockResolvedValue({ data: metrics } as never); + }); + + it('fetches performance metrics with filter parameters', async () => { + const { result } = renderHook(() => usePerformanceMetrics({ region: 'Lagos', limit: 10 }), { wrapper }); + await waitFor(() => expect(result.current.metrics).toEqual(metrics)); + expect(mockApi.get).toHaveBeenCalledWith('/analytics/performance', { params: { region: 'Lagos', limit: 10 } }); + }); + + it('renders KPI cards and all dashboard widget sections', async () => { + render(, { wrapper }); + expect(screen.getByRole('status')).toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: 'Performance metrics' })).toBeInTheDocument(); + expect(screen.getByText('Total deliveries')).toBeInTheDocument(); + expect(screen.getByText('Delivery times')).toBeInTheDocument(); + expect(screen.getByText('Completion rates')).toBeInTheDocument(); + expect(screen.getByText('Regional volume')).toBeInTheDocument(); + expect(screen.getByText('Driver rankings')).toBeInTheDocument(); + expect(screen.getByText(/Bola/)).toBeInTheDocument(); + }); + + it('shows an error and supports retrying the dashboard request', async () => { + mockApi.get + .mockRejectedValueOnce(new Error('Metrics unavailable')) + .mockRejectedValueOnce(new Error('Metrics unavailable')) + .mockResolvedValueOnce({ data: metrics } as never); + render(, { wrapper }); + expect(await screen.findByRole('alert', {}, { timeout: 4_000 })).toHaveTextContent('Metrics unavailable'); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(await screen.findByText(/Bola/)).toBeInTheDocument(); + }); +}); diff --git a/__tests__/components/dashboard/RoiDashboard.test.tsx b/__tests__/components/dashboard/RoiDashboard.test.tsx new file mode 100644 index 0000000..b817a1c --- /dev/null +++ b/__tests__/components/dashboard/RoiDashboard.test.tsx @@ -0,0 +1,64 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { RoiDashboard } from '@/components/dashboard/RoiDashboard'; +import { useRoiMetrics } from '@/hooks/useRoiMetrics'; +import api from '@/lib/api'; + +jest.mock('@/lib/api', () => ({ __esModule: true, default: { get: jest.fn() } })); + +const mockApi = api as jest.Mocked; +const metrics = { + totalRevenue: 12000, + totalCost: 8000, + totalProfit: 4000, + roi: 50, + returnTrends: [{ date: '2026-01', revenue: 12000, cost: 8000, roi: 50 }], + costBreakdown: [{ category: 'Fuel', amount: 3000, percentage: 37.5 }], + driverProfitability: [{ driverId: 'd1', driverName: 'Ada', revenue: 5000, cost: 3000, profit: 2000, roi: 66.7 }], +}; + +function wrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +describe('useRoiMetrics and RoiDashboard', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockApi.get.mockResolvedValue({ data: metrics } as never); + }); + + it('fetches ROI data from the analytics API and unwraps the response', async () => { + const { result } = renderHookWithRoi(); + await waitFor(() => expect(result.current.metrics).toEqual(metrics)); + expect(mockApi.get).toHaveBeenCalledWith('/analytics/roi', { params: {} }); + expect(result.current.isError).toBe(false); + }); + + it('renders KPI, trend, cost, and driver widgets', async () => { + render(, { wrapper }); + expect(screen.getByRole('status')).toHaveTextContent('Loading ROI metrics'); + expect(await screen.findByRole('heading', { name: 'ROI analytics' })).toBeInTheDocument(); + expect(screen.getByText('Total revenue')).toBeInTheDocument(); + expect(screen.getByText('Return trends')).toBeInTheDocument(); + expect(screen.getByText('Cost breakdown')).toBeInTheDocument(); + expect(screen.getByText('Driver profitability')).toBeInTheDocument(); + expect(screen.getByText(/Ada/)).toBeInTheDocument(); + }); + + it('shows an error and retries the request', async () => { + mockApi.get + .mockRejectedValueOnce(new Error('Analytics unavailable')) + .mockRejectedValueOnce(new Error('Analytics unavailable')) + .mockResolvedValueOnce({ data: metrics } as never); + render(, { wrapper }); + expect(await screen.findByRole('alert', {}, { timeout: 4_000 })).toHaveTextContent('Analytics unavailable'); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(await screen.findByText(/Ada/)).toBeInTheDocument(); + }); +}); + +function renderHookWithRoi() { + // Kept local to avoid a second test dependency while retaining QueryClient isolation. + const { renderHook } = require('@testing-library/react') as typeof import('@testing-library/react'); + return renderHook(() => useRoiMetrics(), { wrapper }); +} diff --git a/components/dashboard/PerformanceMetricsDashboard.tsx b/components/dashboard/PerformanceMetricsDashboard.tsx new file mode 100644 index 0000000..c6e1f9b --- /dev/null +++ b/components/dashboard/PerformanceMetricsDashboard.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { usePerformanceMetrics, type PerformanceMetricsParams } from '@/hooks/usePerformanceMetrics'; + +export interface PerformanceMetricsDashboardProps { + params?: PerformanceMetricsParams; +} + +export function PerformanceMetricsDashboard({ params }: PerformanceMetricsDashboardProps) { + const { metrics, isLoading, isError, error, refetch } = usePerformanceMetrics(params); + + if (isLoading) return

Loading performance metrics…

; + if (isError) { + return ( +
+

Unable to load performance metrics{error?.message ? `: ${error.message}` : '.'}

+ +
+ ); + } + if (!metrics) return

No performance metrics available.

; + + return ( +
+
+

Performance metrics

+

Monitor delivery speed, completion, volume, and driver performance.

+
+ +
+ + + +
+ +
+ +
    + {metrics.deliveryTimes.map((point) =>
  • {point.date}{point.averageMinutes} min
  • )} +
+
+ +
    + {metrics.completionRates.map((point) =>
  • {point.date}{point.rate.toFixed(1)}%
  • )} +
+
+ +
    + {metrics.regionalVolume.map((region) =>
  • {region.region}{region.volume.toLocaleString()} ({region.percentage.toFixed(1)}%)
  • )} +
+
+
+ + +
+ + + + {metrics.driverRankings.map((driver, index) => )} +
Driver performance rankings
DriverDeliveriesScore
{index + 1}. {driver.driverName}{driver.deliveries}{driver.score.toFixed(1)}
+
+
+
+ ); +} + +function MetricCard({ label, value }: { label: string; value: string }) { + return
{label}
{value}
; +} + +function Widget({ title, id, children }: { title: string; id: string; children: React.ReactNode }) { + return

{title}

{children}
; +} diff --git a/components/dashboard/RoiDashboard.tsx b/components/dashboard/RoiDashboard.tsx new file mode 100644 index 0000000..12e4c48 --- /dev/null +++ b/components/dashboard/RoiDashboard.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { useRoiMetrics, type RoiMetricsParams } from '@/hooks/useRoiMetrics'; + +export interface RoiDashboardProps { + params?: RoiMetricsParams; +} + +function LoadingState() { + return

Loading ROI metrics…

; +} + +export function RoiDashboard({ params }: RoiDashboardProps) { + const { metrics, isLoading, isError, error, refetch } = useRoiMetrics(params); + + if (isLoading) return ; + if (isError) { + return ( +
+

Unable to load ROI metrics{error?.message ? `: ${error.message}` : '.'}

+ +
+ ); + } + if (!metrics) return

No ROI metrics available.

; + + return ( +
+
+

ROI analytics

+

Returns, costs, and profitability across your delivery network.

+
+ +
+ + + + +
+ +
+ +
    + {metrics.returnTrends.map((point) => ( +
  • + {point.date}{point.roi.toFixed(1)}% +
  • + ))} +
+
+ +
    + {metrics.costBreakdown.map((item) => ( +
  • + {item.category}{item.percentage.toFixed(1)}% +
  • + ))} +
+
+
+ + +
+ + + + {metrics.driverProfitability.map((driver) => ( + + + + + + ))} +
Profitability by driver
DriverProfitROI
{driver.driverName}{driver.profit.toFixed(2)}{driver.roi.toFixed(1)}%
+
+
+
+ ); +} + +function MetricCard({ label, value }: { label: string; value: number | string }) { + return
{label}
{typeof value === 'number' ? value.toFixed(2) : value}
; +} + +function Widget({ title, id, children }: { title: string; id: string; children: React.ReactNode }) { + return

{title}

{children}
; +}