Skip to content

Commit db69615

Browse files
authored
feat(dashboard): enhance client dashboard with new analytics charts and design improvements (#447)
* feat(dashboard): add new chart data types and calculations to repository * feat(dashboard): add 6 new chart components for analytics insights - Add PeriodComparison: weekly performance comparison (this week vs last week) - Add CategoryPerformance: horizontal bar chart of top categories by avg engagement - Add ApprovalTrend: area chart showing 6-month approval rate trend - Add SubmissionCalendar: GitHub-style heatmap for last 90 days activity - Add EngagementDistribution: horizontal bar chart of top items by engagement - Add EngagementRateChart: line chart showing engagement rate over time * refactor(dashboard): improve period-comparison with shared styles and accessibility Changes: - New file: components/dashboard/styles.ts - Centralized design system constants (CARD_BASE_STYLES, TITLE_STYLES, etc.) - Added TOOLTIP_STYLES, CHART_COLORS, SEMANTIC_COLORS - Modified: components/dashboard/period-comparison.tsx - Import shared styles from styles.ts (DRY principle) - Use SEMANTIC_COLORS instead of hardcoded color values - Replace hardcoded "vs" with translation tCommon("VS") - Add accessibility attributes (aria-label, aria-labelledby, role) - Change container to <section> with proper landmarks - Modified: 14 locale files (ar, de, en, es, fr, id, it, ja, ko, nl, pl, pt, ru, zh) - Add client.dashboard.COMMON.VS translation - Add client.dashboard.COMMON.AVG translation * refactor(category-performance): use shared styles and add accessibility * refactor(approval-trend): use shared styles and add accessibility - Replaced local style constants with imports from ./styles - Changed container from <div> to <section> with aria-labelledby - Added id to heading for accessibility linking * refactor(submission-calendar): use shared styles, locale-aware dates, and add accessibility - Replaced local style constants with imports from ./styles - Added useLocale() and passed locale to formatDate() for proper i18n - Changed container from <div> to <section> with aria-labelledby - Added id to heading for accessibility linking * refactor(engagement-distribution): use shared styles and add accessibility - Replaced local style constants with imports from ./styles - Changed container from <div> to <section> with aria-labelledby - Added id to heading for accessibility linking * refactor(engagement-rate-chart): use shared styles, translations, and add accessibility - Replaced local style constants with imports from ./styles - Replaced hardcoded "Avg:" with tCommon("AVG") translation - Changed container from <div> to <section> with aria-labelledby - Added id to heading for accessibility linking * fix(dashboard): fix pie chart label overlap and calendar layout issues * fix(approval-trend): use unique ID for SVG gradient to prevent collisions * fix(category-performance): use exact displayCategory match in tooltip labelFormatter * fix(period-comparison): remove invalid role="list" ARIA attribute * chore: remove unused mock-dashboard-stats
1 parent 19a4223 commit db69615

29 files changed

+2742
-76
lines changed
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"use client";
2+
3+
import { useId } from "react";
4+
import { useTranslations } from "next-intl";
5+
import {
6+
AreaChart,
7+
Area,
8+
XAxis,
9+
YAxis,
10+
CartesianGrid,
11+
Tooltip,
12+
ResponsiveContainer,
13+
} from "recharts";
14+
import type { ApprovalTrendDataExport } from "@/hooks/use-dashboard-stats";
15+
import {
16+
CARD_BASE_STYLES,
17+
TITLE_STYLES,
18+
SUBTITLE_STYLES,
19+
VALUE_STYLES,
20+
TOOLTIP_STYLES,
21+
} from "./styles";
22+
23+
interface ApprovalTrendProps {
24+
data: ApprovalTrendDataExport[];
25+
isLoading?: boolean;
26+
}
27+
28+
export function ApprovalTrend({ data, isLoading = false }: ApprovalTrendProps) {
29+
const gradientId = useId();
30+
const t = useTranslations("client.dashboard.APPROVAL_TREND");
31+
32+
if (isLoading) {
33+
return (
34+
<div className={CARD_BASE_STYLES}>
35+
<div className="animate-pulse">
36+
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-sm mb-4 w-1/3"></div>
37+
<div className="h-64 bg-gray-200 dark:bg-gray-700 rounded-sm"></div>
38+
</div>
39+
</div>
40+
);
41+
}
42+
43+
if (!data || data.length === 0) {
44+
return (
45+
<div className={CARD_BASE_STYLES}>
46+
<h3 className={TITLE_STYLES}>{t("TITLE")}</h3>
47+
<p className="text-gray-500 dark:text-gray-400 text-center py-8">
48+
{t("NO_DATA")}
49+
</p>
50+
<p className={`${SUBTITLE_STYLES} text-center`}>
51+
{t("NO_DATA_DESC")}
52+
</p>
53+
</div>
54+
);
55+
}
56+
57+
const latestRate = data[data.length - 1]?.rate || 0;
58+
const firstRate = data[0]?.rate || 0;
59+
const rateChange = latestRate - firstRate;
60+
const totalSubmissions = data.reduce((sum, item) => sum + item.total, 0);
61+
const totalApproved = data.reduce((sum, item) => sum + item.approved, 0);
62+
63+
return (
64+
<section className={CARD_BASE_STYLES} aria-labelledby="approval-trend-title">
65+
<div className="flex items-center justify-between mb-4">
66+
<div>
67+
<h3 id="approval-trend-title" className={TITLE_STYLES}>{t("TITLE")}</h3>
68+
<p className={SUBTITLE_STYLES}>{t("SUBTITLE")}</p>
69+
</div>
70+
<div className="text-right">
71+
<div className={VALUE_STYLES}>{latestRate.toFixed(0)}%</div>
72+
<div
73+
className={`text-xs ${
74+
rateChange >= 0
75+
? "text-green-600 dark:text-green-400"
76+
: "text-red-600 dark:text-red-400"
77+
}`}
78+
>
79+
{rateChange >= 0 ? "+" : ""}
80+
{rateChange.toFixed(0)}%
81+
</div>
82+
</div>
83+
</div>
84+
<ResponsiveContainer width="100%" height={200}>
85+
<AreaChart
86+
data={data}
87+
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
88+
>
89+
<defs>
90+
<linearGradient
91+
id={`approvalGradient-${gradientId}`}
92+
x1="0"
93+
y1="0"
94+
x2="0"
95+
y2="1"
96+
>
97+
<stop
98+
offset="5%"
99+
stopColor="#10B981"
100+
stopOpacity={0.3}
101+
/>
102+
<stop
103+
offset="95%"
104+
stopColor="#10B981"
105+
stopOpacity={0}
106+
/>
107+
</linearGradient>
108+
</defs>
109+
<CartesianGrid
110+
strokeDasharray="3 3"
111+
stroke="var(--tw-prose-hr, #e5e7eb)"
112+
vertical={false}
113+
/>
114+
<XAxis dataKey="month" stroke="#6B7280" fontSize={12} />
115+
<YAxis
116+
stroke="#6B7280"
117+
fontSize={12}
118+
domain={[0, 100]}
119+
tickFormatter={(value: number) => `${value}%`}
120+
/>
121+
<Tooltip
122+
contentStyle={TOOLTIP_STYLES}
123+
formatter={(value) => [
124+
`${Number(value).toFixed(1)}%`,
125+
t("APPROVAL_RATE"),
126+
]}
127+
/>
128+
<Area
129+
type="monotone"
130+
dataKey="rate"
131+
stroke="#10B981"
132+
strokeWidth={2}
133+
fill={`url(#approvalGradient-${gradientId})`}
134+
/>
135+
</AreaChart>
136+
</ResponsiveContainer>
137+
<div className="mt-4 flex justify-between text-xs text-gray-500 dark:text-gray-400">
138+
<span>
139+
{t("TOTAL")}: {totalSubmissions}
140+
</span>
141+
<span>
142+
{t("APPROVED")}: {totalApproved}
143+
</span>
144+
</div>
145+
</section>
146+
);
147+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"use client";
2+
3+
import { useTranslations } from "next-intl";
4+
import {
5+
BarChart,
6+
Bar,
7+
XAxis,
8+
YAxis,
9+
CartesianGrid,
10+
Tooltip,
11+
ResponsiveContainer,
12+
Cell,
13+
} from "recharts";
14+
import type { CategoryPerformanceDataExport } from "@/hooks/use-dashboard-stats";
15+
import {
16+
CARD_BASE_STYLES,
17+
TITLE_STYLES,
18+
SUBTITLE_STYLES,
19+
TOOLTIP_STYLES,
20+
CHART_COLORS,
21+
} from "./styles";
22+
23+
interface CategoryPerformanceProps {
24+
data: CategoryPerformanceDataExport[];
25+
isLoading?: boolean;
26+
}
27+
28+
export function CategoryPerformance({ data, isLoading = false }: CategoryPerformanceProps) {
29+
const t = useTranslations("client.dashboard.CATEGORY_PERFORMANCE");
30+
31+
if (isLoading) {
32+
return (
33+
<div className={CARD_BASE_STYLES}>
34+
<div className="animate-pulse">
35+
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded-sm mb-4 w-1/3"></div>
36+
<div className="h-64 bg-gray-200 dark:bg-gray-700 rounded-sm"></div>
37+
</div>
38+
</div>
39+
);
40+
}
41+
42+
if (!data || data.length === 0) {
43+
return (
44+
<div className={CARD_BASE_STYLES}>
45+
<h3 className={TITLE_STYLES}>{t("TITLE")}</h3>
46+
<p className="text-gray-500 dark:text-gray-400 text-center py-8">
47+
{t("NO_DATA")}
48+
</p>
49+
<p className={`${SUBTITLE_STYLES} text-center`}>
50+
{t("NO_DATA_DESC")}
51+
</p>
52+
</div>
53+
);
54+
}
55+
56+
const chartData = data.map((item) => ({
57+
...item,
58+
displayCategory:
59+
item.category.length > 15
60+
? `${item.category.substring(0, 15)}...`
61+
: item.category,
62+
}));
63+
64+
return (
65+
<section className={CARD_BASE_STYLES} aria-labelledby="category-performance-title">
66+
<div className="flex items-center justify-between mb-4">
67+
<h3 id="category-performance-title" className={TITLE_STYLES}>{t("TITLE")}</h3>
68+
<span className={SUBTITLE_STYLES}>{t("SUBTITLE")}</span>
69+
</div>
70+
<ResponsiveContainer width="100%" height={250}>
71+
<BarChart
72+
data={chartData}
73+
layout="vertical"
74+
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
75+
>
76+
<CartesianGrid
77+
strokeDasharray="3 3"
78+
stroke="var(--tw-prose-hr, #e5e7eb)"
79+
horizontal={true}
80+
vertical={false}
81+
/>
82+
<XAxis
83+
type="number"
84+
stroke="#6B7280"
85+
fontSize={12}
86+
tickFormatter={(value: number) => value.toFixed(1)}
87+
/>
88+
<YAxis
89+
type="category"
90+
dataKey="displayCategory"
91+
stroke="#6B7280"
92+
fontSize={12}
93+
width={100}
94+
/>
95+
<Tooltip
96+
contentStyle={TOOLTIP_STYLES}
97+
formatter={(value, name) => {
98+
if (name === "avgEngagement") {
99+
return [Number(value).toFixed(2), t("AVG_ENGAGEMENT")];
100+
}
101+
return [value, String(name)];
102+
}}
103+
labelFormatter={(label) => {
104+
const item = chartData.find(
105+
(d) => d.displayCategory === String(label)
106+
);
107+
return item ? item.category : String(label);
108+
}}
109+
/>
110+
<Bar dataKey="avgEngagement" radius={[0, 4, 4, 0]}>
111+
{chartData.map((_, index) => (
112+
<Cell
113+
key={`cell-${index}`}
114+
fill={CHART_COLORS[index % CHART_COLORS.length]}
115+
/>
116+
))}
117+
</Bar>
118+
</BarChart>
119+
</ResponsiveContainer>
120+
<div className="mt-4 grid grid-cols-2 gap-2 text-xs">
121+
{data.slice(0, 4).map((item, index) => (
122+
<div key={item.category} className="flex items-center gap-2">
123+
<div
124+
className="h-2 w-2 rounded-full shrink-0"
125+
style={{
126+
backgroundColor: CHART_COLORS[index % CHART_COLORS.length],
127+
}}
128+
/>
129+
<span className="text-gray-600 dark:text-gray-400 truncate">
130+
{item.category}
131+
</span>
132+
<span className="text-gray-900 dark:text-gray-100 font-medium shrink-0">
133+
{item.itemCount} {t("ITEMS").toLowerCase()}
134+
</span>
135+
</div>
136+
))}
137+
</div>
138+
</section>
139+
);
140+
}

components/dashboard/dashboard-content.tsx

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ import { SubmissionTimeline } from "./submission-timeline";
1515
import { EngagementOverview } from "./engagement-overview";
1616
import { StatusBreakdown } from "./status-breakdown";
1717
import { TopItems } from "./top-items";
18+
import { PeriodComparison } from "./period-comparison";
19+
import { CategoryPerformance } from "./category-performance";
20+
import { ApprovalTrend } from "./approval-trend";
21+
import { SubmissionCalendar } from "./submission-calendar";
22+
import { EngagementDistribution } from "./engagement-distribution";
23+
import { EngagementRateChart } from "./engagement-rate-chart";
1824
import { useDashboardStats } from "@/hooks/use-dashboard-stats";
1925
import { Button } from "@/components/ui/button";
2026

@@ -99,7 +105,15 @@ export function DashboardContent({ session }: DashboardContentProps) {
99105
/>
100106
</div>
101107

102-
{/* New Charts Section */}
108+
{/* Period Comparison */}
109+
<div className="mb-8">
110+
<PeriodComparison
111+
data={stats?.periodComparison}
112+
isLoading={!stats}
113+
/>
114+
</div>
115+
116+
{/* Submission Timeline & Status */}
103117
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
104118
<SubmissionTimeline
105119
data={stats?.submissionTimeline || []}
@@ -113,8 +127,20 @@ export function DashboardContent({ session }: DashboardContentProps) {
113127

114128
{/* Engagement Overview */}
115129
<div className="mb-8">
116-
<EngagementOverview
117-
data={stats?.engagementOverview || []}
130+
<EngagementOverview
131+
data={stats?.engagementOverview || []}
132+
isLoading={!stats}
133+
/>
134+
</div>
135+
136+
{/* Category Performance & Approval Trend */}
137+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
138+
<CategoryPerformance
139+
data={stats?.categoryPerformance || []}
140+
isLoading={!stats}
141+
/>
142+
<ApprovalTrend
143+
data={stats?.approvalTrend || []}
118144
isLoading={!stats}
119145
/>
120146
</div>
@@ -135,6 +161,31 @@ export function DashboardContent({ session }: DashboardContentProps) {
135161
</div>
136162
</div>
137163

164+
{/* Submission Calendar - Full Width */}
165+
<div className="mb-8">
166+
<SubmissionCalendar
167+
data={stats?.submissionCalendar || []}
168+
isLoading={!stats}
169+
/>
170+
</div>
171+
172+
{/* Engagement Distribution */}
173+
<div className="mb-8">
174+
<EngagementDistribution
175+
data={stats?.engagementDistribution || []}
176+
isLoading={!stats}
177+
/>
178+
</div>
179+
180+
{/* Engagement Rate Chart */}
181+
<div className="mb-8">
182+
<EngagementRateChart
183+
engagementOverview={stats?.engagementOverview || []}
184+
totalSubmissions={stats?.totalSubmissions || 0}
185+
isLoading={!stats}
186+
/>
187+
</div>
188+
138189
{/* Weekly Activity Chart */}
139190
<div className="mb-8">
140191
<ActivityChart

components/dashboard/engagement-chart.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,10 @@ export function EngagementChart({ data, isLoading = false }: EngagementChartProp
3838
cx="50%"
3939
cy="50%"
4040
labelLine={false}
41-
label={({ name, percent }: { name?: string; percent?: number }) =>
42-
`${name ?? 'Unknown'} ${((percent ?? 0) * 100).toFixed(0)}%`}
41+
label={({ name, percent }: { name?: string; percent?: number }) => {
42+
if ((percent ?? 0) === 0) return '';
43+
return `${name ?? 'Unknown'} ${((percent ?? 0) * 100).toFixed(0)}%`;
44+
}}
4345
outerRadius={80}
4446
fill="#8884d8"
4547
dataKey="value"

0 commit comments

Comments
 (0)