+
-
-
-
-
+ {overallStats.stats && (
+
+ )}
-
-
-
- {stats &&
}
+
+ {/* Question History - Recent answers */}
+
+
+
+
+
- {stats &&
}
- {/** suggested q's and analysis blocks TODO: CHANGE SUGGESTED QUESTIONS TO STREAK DATA (I THINK) */}
-
-
);
diff --git a/src/app/globals.css b/src/app/globals.css
index 9957ede03..9c7d55f76 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -8,6 +8,16 @@
--color-secondary: #ffffff;
--color-accent: #5b61d6;
+
+ /* Add HSL format for accent color that Tailwind expects */
+ --accent: 237 65% 64%; /* HSL values for #5b61d6 */
+ --accent-foreground: 0 0% 100%; /* White text on accent */
+}
+
+/* Define explicitly for dark mode as well */
+.dark {
+ --accent: 237 65% 64%; /* Same accent color in dark mode */
+ --accent-foreground: 0 0% 100%;
}
body {
@@ -265,8 +275,6 @@ html {
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
- --accent: 237 59.3% 60%;
- --accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
diff --git a/src/components/app/navigation/sidebar.tsx b/src/components/app/navigation/sidebar.tsx
index 6ea925b97..435fe04bb 100644
--- a/src/components/app/navigation/sidebar.tsx
+++ b/src/components/app/navigation/sidebar.tsx
@@ -168,8 +168,8 @@ export function AppSidebar({ user, profile, suggestion }: AppSidebarProps) {
url: '/statistics',
icon: BChart3,
tooltip: 'Statistics',
+ defaultOpen: true,
animatable: true,
- defaultOpen: false,
subItems: [
{
title: 'Overview',
diff --git a/src/components/app/roadmaps/[uid]/roadmap-stats-chart.tsx b/src/components/app/roadmaps/[uid]/roadmap-stats-chart.tsx
index 8ea1c74fa..3480f1b79 100644
--- a/src/components/app/roadmaps/[uid]/roadmap-stats-chart.tsx
+++ b/src/components/app/roadmaps/[uid]/roadmap-stats-chart.tsx
@@ -8,7 +8,7 @@ import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
-} from '@/components/ui/chart';
+} from '@/components/charts/chart';
import type { UserRoadmapsWithAnswers } from '@/types';
const chartConfig = {
diff --git a/src/components/app/statistics/difficulty-radial-chart.stories.tsx b/src/components/app/statistics/difficulty-radial-chart.stories.tsx
new file mode 100644
index 000000000..6c068febc
--- /dev/null
+++ b/src/components/app/statistics/difficulty-radial-chart.stories.tsx
@@ -0,0 +1,402 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { StatsChartData } from '@/types/Stats';
+import DifficultyRadialChart from './difficulty-radial-chart';
+
+const meta = {
+ component: DifficultyRadialChart,
+ parameters: {
+ layout: 'fullscreen',
+ backgrounds: {
+ default: 'dark',
+ values: [{ name: 'dark', value: '#090909' }],
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+ tags: ['autodocs'],
+} satisfies Meta
;
+
+export default meta;
+
+type Story = StoryObj;
+
+// Mock data that matches the expected StatsChartData structure
+const mockData: StatsChartData = {
+ all: {
+ totalQuestions: 175,
+ tagCounts: {
+ javascript: 52,
+ 'data-types': 1,
+ 'equality-comparison': 1,
+ objects: 17,
+ nested: 2,
+ React: 1,
+ useState: 3,
+ 'state-management': 3,
+ nullish: 1,
+ fundamentals: 2,
+ iteration: 2,
+ arrays: 27,
+ sorting: 1,
+ 'comparator functions': 1,
+ Arrays: 1,
+ Methods: 1,
+ push: 2,
+ 'Array-Manipulation': 1,
+ 'control-flow': 2,
+ switch: 1,
+ validation: 4,
+ 'error-handling': 2,
+ properties: 3,
+ reduce: 14,
+ Strings: 3,
+ 'Regular Expressions': 1,
+ DOM: 1,
+ events: 2,
+ recursion: 1,
+ 'array-methods': 7,
+ 'Array-manipulation': 1,
+ strings: 10,
+ some: 1,
+ 'Frequency-Counting': 2,
+ Optimization: 1,
+ 'Data-Structures': 1,
+ 'Hash-Maps': 1,
+ regex: 3,
+ filter: 6,
+ JavaScript: 7,
+ calculation: 1,
+ discount: 1,
+ map: 6,
+ search: 1,
+ 'data-structures': 1,
+ functions: 4,
+ arithmetic: 1,
+ Functions: 1,
+ 'Error Handling': 1,
+ 'Async Programming': 1,
+ filtering: 1,
+ react: 6,
+ 'react-hooks': 4,
+ closures: 3,
+ setState: 1,
+ async: 2,
+ 'data-aggregation': 1,
+ variables: 3,
+ types: 1,
+ split: 1,
+ Set: 1,
+ unique: 1,
+ concatenation: 2,
+ math: 2,
+ transformation: 1,
+ caching: 1,
+ timers: 1,
+ 'garbage-collection': 1,
+ setTimeout: 2,
+ useMemo: 1,
+ useCallback: 1,
+ optimization: 1,
+ memoization: 1,
+ 'react-performance': 1,
+ const: 1,
+ immutability: 1,
+ authentication: 2,
+ timing: 1,
+ every: 1,
+ 'boolean-logic': 1,
+ 'async-programming': 3,
+ queues: 1,
+ batching: 3,
+ promises: 3,
+ 'type-checking': 2,
+ loops: 6,
+ 'while-loop': 1,
+ increment: 1,
+ aggregation: 1,
+ accumulator: 1,
+ 'real-world-applications': 1,
+ accumulators: 1,
+ 'object-references': 1,
+ maps: 1,
+ mutability: 1,
+ proxy: 1,
+ reflection: 1,
+ 'reactive-programming': 1,
+ 'state-updates': 1,
+ generators: 1,
+ iterators: 1,
+ 'memory-optimization': 1,
+ 'react-suspense': 1,
+ 'promise-management': 1,
+ 'data-fetching': 1,
+ 'async programming': 1,
+ 'error handling': 1,
+ fetch: 1,
+ Algorithms: 1,
+ 'Data Transformation': 1,
+ 'object-iteration': 2,
+ 'object manipulation': 1,
+ 'data-processing': 1,
+ 'string-manipulation': 1,
+ scope: 1,
+ 'class-components': 1,
+ localStorage: 1,
+ JSON: 1,
+ classes: 1,
+ 'browser-storage': 1,
+ conditions: 1,
+ 'if-statements': 2,
+ security: 1,
+ cybersecurity: 1,
+ 'if-else': 1,
+ comparison: 1,
+ logic: 1,
+ methods: 1,
+ length: 1,
+ 'numeric-values': 1,
+ http: 1,
+ 'http-codes': 1,
+ 'status-codes': 1,
+ },
+ tags: [
+ 'javascript',
+ 'data-types',
+ 'equality-comparison',
+ 'objects',
+ 'nested',
+ 'React',
+ 'useState',
+ 'state-management',
+ 'nullish',
+ 'fundamentals',
+ 'iteration',
+ 'arrays',
+ 'sorting',
+ 'comparator functions',
+ 'Arrays',
+ 'Methods',
+ 'push',
+ 'Array-Manipulation',
+ 'control-flow',
+ 'switch',
+ 'validation',
+ 'error-handling',
+ 'properties',
+ 'reduce',
+ 'Strings',
+ 'Regular Expressions',
+ 'DOM',
+ 'events',
+ 'recursion',
+ 'array-methods',
+ 'Array-manipulation',
+ 'strings',
+ 'some',
+ 'Frequency-Counting',
+ 'Optimization',
+ 'Data-Structures',
+ 'Hash-Maps',
+ 'regex',
+ 'filter',
+ 'JavaScript',
+ 'calculation',
+ 'discount',
+ 'map',
+ 'search',
+ 'data-structures',
+ 'functions',
+ 'arithmetic',
+ 'Functions',
+ 'Error Handling',
+ 'Async Programming',
+ 'filtering',
+ 'react',
+ 'react-hooks',
+ 'closures',
+ 'setState',
+ 'async',
+ 'data-aggregation',
+ 'variables',
+ 'types',
+ 'split',
+ 'Set',
+ 'unique',
+ 'concatenation',
+ 'math',
+ 'transformation',
+ 'caching',
+ 'timers',
+ 'garbage-collection',
+ 'setTimeout',
+ 'useMemo',
+ 'useCallback',
+ 'optimization',
+ 'memoization',
+ 'react-performance',
+ 'const',
+ 'immutability',
+ 'authentication',
+ 'timing',
+ 'every',
+ 'boolean-logic',
+ 'async-programming',
+ 'queues',
+ 'batching',
+ 'promises',
+ 'type-checking',
+ 'loops',
+ 'while-loop',
+ 'increment',
+ 'aggregation',
+ 'accumulator',
+ 'real-world-applications',
+ 'accumulators',
+ 'object-references',
+ 'maps',
+ 'mutability',
+ 'proxy',
+ 'reflection',
+ 'reactive-programming',
+ 'state-updates',
+ 'generators',
+ 'iterators',
+ 'memory-optimization',
+ 'react-suspense',
+ 'promise-management',
+ 'data-fetching',
+ 'async programming',
+ 'error handling',
+ 'fetch',
+ 'Algorithms',
+ 'Data Transformation',
+ 'object-iteration',
+ 'object manipulation',
+ 'data-processing',
+ 'string-manipulation',
+ 'scope',
+ 'class-components',
+ 'localStorage',
+ 'JSON',
+ 'classes',
+ 'browser-storage',
+ 'conditions',
+ 'if-statements',
+ 'security',
+ 'cybersecurity',
+ 'if-else',
+ 'comparison',
+ 'logic',
+ 'methods',
+ 'length',
+ 'numeric-values',
+ 'http',
+ 'http-codes',
+ 'status-codes',
+ ],
+ difficulties: { BEGINNER: 101, EASY: 34, MEDIUM: 31, HARD: 9 },
+ },
+};
+
+// Mock data with multiple time periods
+const mockTimePeriodsData: StatsChartData = {
+ '2023-01,2023': {
+ totalQuestions: 35,
+ tagCounts: {},
+ tags: [],
+ difficulties: {
+ BEGINNER: 20,
+ EASY: 10,
+ MEDIUM: 5,
+ HARD: 0,
+ },
+ },
+ '2023-02,2023': {
+ totalQuestions: 47,
+ tagCounts: {},
+ tags: [],
+ difficulties: {
+ BEGINNER: 22,
+ EASY: 15,
+ MEDIUM: 7,
+ HARD: 3,
+ },
+ },
+ '2023-03,2023': {
+ totalQuestions: 93,
+ tagCounts: {},
+ tags: [],
+ difficulties: {
+ BEGINNER: 59,
+ EASY: 9,
+ MEDIUM: 19,
+ HARD: 6,
+ },
+ },
+};
+
+export const Default: Story = {
+ args: {
+ questionData: mockData,
+ },
+ decorators: [
+ (Story) => (
+
+
+ Question Difficulty Distribution
+
+
+
+ ),
+ ],
+};
+
+export const WithMultipleTimePeriods: Story = {
+ args: {
+ questionData: mockTimePeriodsData,
+ },
+ decorators: [
+ (Story) => (
+
+
+ Question Difficulty by Time Periods
+
+
+
+ ),
+ ],
+};
diff --git a/src/components/app/statistics/difficulty-radial-chart.tsx b/src/components/app/statistics/difficulty-radial-chart.tsx
new file mode 100644
index 000000000..fd6e2c049
--- /dev/null
+++ b/src/components/app/statistics/difficulty-radial-chart.tsx
@@ -0,0 +1,165 @@
+'use client';
+
+import { useMemo } from 'react';
+import { RadialBarChart, RadialBar, Legend, ResponsiveContainer, Tooltip } from 'recharts';
+import { StatsChartData } from '@/types/Stats';
+import { Button } from '@/components/ui/button';
+import { Separator } from '@/components/ui/separator';
+
+// Define colors for each difficulty
+// Use the same difficulty colors as defined in the app's utility functions
+const DIFFICULTY_COLORS = {
+ BEGINNER: '#3b82f6', // blue-500
+ EASY: '#22c55e', // green-500
+ MEDIUM: '#eab308', // yellow-500
+ HARD: '#ef4444', // red-500
+};
+
+// Map difficulty to friendly names
+const DIFFICULTY_LABELS = {
+ BEGINNER: 'Beginner',
+ EASY: 'Easy',
+ MEDIUM: 'Medium',
+ HARD: 'Hard',
+};
+
+interface DifficultyRadialChartProps {
+ questionData: StatsChartData;
+ legend?: boolean;
+}
+
+export default function DifficultyRadialChart({
+ questionData,
+ legend = true,
+}: DifficultyRadialChartProps) {
+ // Calculate total questions by difficulty
+ const difficultyData = useMemo(() => {
+ // Create object to store totals by difficulty
+ const totalsByDifficulty: Record = {};
+ let grandTotal = 0;
+
+ // Sum up all question counts by difficulty across all time periods
+ Object.values(questionData).forEach((data) => {
+ // Only process entries that have difficulties data
+ if (data.difficulties) {
+ Object.entries(data.difficulties).forEach(([difficulty, count]) => {
+ // Ensure count is treated as a number
+ const countValue = count ? Number(count) : 0;
+ totalsByDifficulty[difficulty] = (totalsByDifficulty[difficulty] || 0) + countValue;
+ grandTotal += countValue;
+ });
+ }
+ });
+
+ // Convert to array format for radial chart
+ // Sort from highest to lowest count for better visualization
+ const chartData = Object.entries(totalsByDifficulty)
+ .filter(([_, count]) => count > 0) // Only include non-zero counts
+ .sort((a, b) => b[1] - a[1]) // Sort by count (descending)
+ .map(([difficulty, count], index) => {
+ // Calculate the angle for the radial chart
+ return {
+ name: DIFFICULTY_LABELS[difficulty as keyof typeof DIFFICULTY_LABELS] || difficulty,
+ value: count,
+ difficulty,
+ fill: DIFFICULTY_COLORS[difficulty as keyof typeof DIFFICULTY_COLORS] || '#888',
+ percentage: grandTotal > 0 ? ((count / grandTotal) * 100).toFixed(1) : '0',
+ };
+ });
+
+ return { chartData, grandTotal };
+ }, [questionData]);
+
+ // Generate a legend that shows both counts and percentages
+ const LegendContent = () => {
+ return (
+
+ {difficultyData.chartData.map((entry, index) => (
+
+
+
+ {entry.name}
+
+ {entry.value} ({entry.percentage}%)
+
+
+
+ ))}
+
+ );
+ };
+
+ // Custom tooltip component
+ const CustomTooltip = ({ active, payload }: any) => {
+ if (active && payload && payload.length) {
+ const data = payload[0].payload;
+ return (
+
+
{data.name}
+
+
+
+
Questions: {data.value}
+
+
+ );
+ }
+ return null;
+ };
+
+ return (
+
+ {difficultyData.grandTotal > 0 ? (
+
+
+
+
+ value,
+ }}
+ dataKey="value"
+ startAngle={45}
+ endAngle={450}
+ />
+ } animationDuration={500} />
+
+
+
+
+ {legend && (
+
+
+
+
+ Total: {difficultyData.grandTotal} questions
+
+
+ )}
+
+ ) : (
+
+
+ No difficulty data available due to lack of questions answered
+
+
Start answering now
+
+ )}
+
+ );
+}
diff --git a/src/components/app/statistics/question-history.stories.tsx b/src/components/app/statistics/question-history.stories.tsx
new file mode 100644
index 000000000..1192283f6
--- /dev/null
+++ b/src/components/app/statistics/question-history.stories.tsx
@@ -0,0 +1,235 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import QuestionHistory from './question-history';
+import { Suspense } from 'react';
+
+function QuestionHistoryStory({ recentAnswers }: { recentAnswers: any[] }) {
+ return (
+
+
+
+
+
+ );
+}
+
+const meta = {
+ component: QuestionHistoryStory,
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ recentAnswers: [
+ {
+ uid: 'cm8lq0t5a00084epgpsqq75ap',
+ createdAt: '2025-03-23T14:16:06.287Z',
+ timeTaken: 0,
+ userAnswerUid: null,
+ correctAnswer: true,
+ userUid: '3a57d7e8-8b80-483b-93d0-70fe1f06b0c0',
+ questionUid: 'dh3982dh893ucb98nc90-cnuw2-xchmovkd',
+ updatedAt: '2025-03-23T14:16:06.287Z',
+ questionDate: '',
+ difficulty: 'EASY',
+ question: {
+ uid: 'dh3982dh893ucb98nc90-cnuw2-xchmovkd',
+ question: 'What is Git?',
+ createdAt: '2025-03-14T17:15:28.000Z',
+ updatedAt: '2025-03-23T20:17:51.904Z',
+ questionDate: 'NULL',
+ answerResource: null,
+ correctAnswer: 'NULL',
+ codeSnippet: null,
+ hint: null,
+ dailyQuestion: false,
+ difficulty: 'BEGINNER',
+ customQuestion: false,
+ slug: 'what-is-git',
+ slugGenerated: true,
+ description: null,
+ title: null,
+ questionType: 'MULTIPLE_CHOICE',
+ expectedParams: null,
+ functionName: null,
+ returnType: null,
+ testCases: null,
+ nextQuestionSlug: null,
+ previousQuestionSlug: null,
+ isPremiumQuestion: false,
+ afterQuestionInfo: null,
+ tags: [],
+ },
+ },
+ {
+ uid: 'cm8lq0t5a00094epgnckxio8l',
+ createdAt: '2025-03-23T14:16:06.287Z',
+ timeTaken: 0,
+ userAnswerUid: null,
+ correctAnswer: true,
+ userUid: '3a57d7e8-8b80-483b-93d0-70fe1f06b0c0',
+ questionUid: 'dn3u29x98n23n9cm0c3-d32nuixwenciub',
+ updatedAt: '2025-03-23T14:16:06.287Z',
+ questionDate: '',
+ difficulty: 'EASY',
+ question: {
+ uid: 'dn3u29x98n23n9cm0c3-d32nuixwenciub',
+ question: 'Which of these is a programming loop?',
+ createdAt: '2025-03-14T17:17:04.000Z',
+ updatedAt: '2025-03-23T20:17:51.904Z',
+ questionDate: 'NULL',
+ answerResource: null,
+ correctAnswer: 'NULL',
+ codeSnippet: null,
+ hint: null,
+ dailyQuestion: false,
+ difficulty: 'BEGINNER',
+ customQuestion: false,
+ slug: 'which-programming-loop',
+ slugGenerated: true,
+ description: null,
+ title: null,
+ questionType: 'MULTIPLE_CHOICE',
+ expectedParams: null,
+ functionName: null,
+ returnType: null,
+ testCases: null,
+ nextQuestionSlug: null,
+ previousQuestionSlug: null,
+ isPremiumQuestion: false,
+ afterQuestionInfo: null,
+ tags: [],
+ },
+ },
+ {
+ uid: 'cm8lq0t5a00074epgzp0gf4uj',
+ createdAt: '2025-03-23T14:16:06.287Z',
+ timeTaken: 0,
+ userAnswerUid: null,
+ correctAnswer: true,
+ userUid: '3a57d7e8-8b80-483b-93d0-70fe1f06b0c0',
+ questionUid: 'fj9348dhj2839h8d7wj9c0-dwn89nb2ubuc8w',
+ updatedAt: '2025-03-23T14:16:06.287Z',
+ questionDate: '',
+ difficulty: 'EASY',
+ question: {
+ uid: 'fj9348dhj2839h8d7wj9c0-dwn89nb2ubuc8w',
+ question: 'What is a function in programming?',
+ createdAt: '2025-03-14T17:14:25.000Z',
+ updatedAt: '2025-03-23T20:17:51.904Z',
+ questionDate: 'NULL',
+ answerResource: null,
+ correctAnswer: 'NULL',
+ codeSnippet: null,
+ hint: null,
+ dailyQuestion: false,
+ difficulty: 'BEGINNER',
+ customQuestion: false,
+ slug: 'what-is-a-function-programming',
+ slugGenerated: true,
+ description: null,
+ title: null,
+ questionType: 'MULTIPLE_CHOICE',
+ expectedParams: null,
+ functionName: null,
+ returnType: null,
+ testCases: null,
+ nextQuestionSlug: null,
+ previousQuestionSlug: null,
+ isPremiumQuestion: false,
+ afterQuestionInfo: null,
+ tags: [],
+ },
+ },
+ {
+ uid: 'cm8lq00k400054epgdazdm5kj',
+ createdAt: '2025-03-23T14:15:29.236Z',
+ timeTaken: 0,
+ userAnswerUid: null,
+ correctAnswer: false,
+ userUid: '3a57d7e8-8b80-483b-93d0-70fe1f06b0c0',
+ questionUid: 'dh3982dh893ucb98nc90-cnuw2-xchmovkd',
+ updatedAt: '2025-03-23T14:15:29.236Z',
+ questionDate: '',
+ difficulty: 'EASY',
+ question: {
+ uid: 'dh3982dh893ucb98nc90-cnuw2-xchmovkd',
+ question: 'What is Git?',
+ createdAt: '2025-03-14T17:15:28.000Z',
+ updatedAt: '2025-03-23T20:17:51.904Z',
+ questionDate: 'NULL',
+ answerResource: null,
+ correctAnswer: 'NULL',
+ codeSnippet: null,
+ hint: null,
+ dailyQuestion: false,
+ difficulty: 'BEGINNER',
+ customQuestion: false,
+ slug: 'what-is-git',
+ slugGenerated: true,
+ description: null,
+ title: null,
+ questionType: 'MULTIPLE_CHOICE',
+ expectedParams: null,
+ functionName: null,
+ returnType: null,
+ testCases: null,
+ nextQuestionSlug: null,
+ previousQuestionSlug: null,
+ isPremiumQuestion: false,
+ afterQuestionInfo: null,
+ tags: [],
+ },
+ },
+ {
+ uid: 'cm8lq00k400064epg36nakibm',
+ createdAt: '2025-03-23T14:15:29.236Z',
+ timeTaken: 0,
+ userAnswerUid: null,
+ correctAnswer: true,
+ userUid: '3a57d7e8-8b80-483b-93d0-70fe1f06b0c0',
+ questionUid: 'dn3u29x98n23n9cm0c3-d32nuixwenciub',
+ updatedAt: '2025-03-23T14:15:29.236Z',
+ questionDate: '',
+ difficulty: 'EASY',
+ question: {
+ uid: 'dn3u29x98n23n9cm0c3-d32nuixwenciub',
+ question: 'Which of these is a programming loop?',
+ createdAt: '2025-03-14T17:17:04.000Z',
+ updatedAt: '2025-03-23T20:17:51.904Z',
+ questionDate: 'NULL',
+ answerResource: null,
+ correctAnswer: 'NULL',
+ codeSnippet: null,
+ hint: null,
+ dailyQuestion: false,
+ difficulty: 'BEGINNER',
+ customQuestion: false,
+ slug: 'which-programming-loop',
+ slugGenerated: true,
+ description: null,
+ title: null,
+ questionType: 'MULTIPLE_CHOICE',
+ expectedParams: null,
+ functionName: null,
+ returnType: null,
+ testCases: null,
+ nextQuestionSlug: null,
+ previousQuestionSlug: null,
+ isPremiumQuestion: false,
+ afterQuestionInfo: null,
+ tags: [],
+ },
+ },
+ ],
+ },
+};
+
+export const Empty: Story = {
+ args: {
+ recentAnswers: [],
+ },
+};
diff --git a/src/components/app/statistics/question-history.tsx b/src/components/app/statistics/question-history.tsx
new file mode 100644
index 000000000..11191fcb0
--- /dev/null
+++ b/src/components/app/statistics/question-history.tsx
@@ -0,0 +1,209 @@
+import { formatDistanceToNow } from 'date-fns';
+import { CheckCircle, Ellipsis, XCircle, Clock, Calendar, BarChart3, Info } from 'lucide-react';
+import Link from 'next/link';
+import { format } from 'date-fns';
+
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import SPulse2 from '@/components/ui/icons/s-pulse-2';
+import { cn } from '@/lib/utils';
+import type { RecentUserAnswer } from '@/utils/data/answers/get-user-answer';
+import { Separator } from '@/components/ui/separator';
+
+// Extend the RecentUserAnswer interface with additional properties we need
+interface ExtendedRecentUserAnswer extends RecentUserAnswer {
+ timeTaken?: number;
+ question: {
+ title: string | null;
+ slug: string | null;
+ question: string | null;
+ difficulty?: string;
+ };
+}
+
+interface QuestionHistoryProps {
+ className?: string;
+ recentAnswers: ExtendedRecentUserAnswer[];
+ dropdownPosition?: 'left' | 'right';
+}
+
+// Format time taken in a more readable format
+const formatTimeTaken = (seconds: number | null | undefined) => {
+ if (!seconds) return 'N/A';
+
+ if (seconds < 60) {
+ return `${seconds} seconds`;
+ }
+
+ const minutes = Math.floor(seconds / 60);
+ const remainingSeconds = seconds % 60;
+
+ return `${minutes}m ${remainingSeconds}s`;
+};
+
+function QuestionDetailDropdown({
+ answer,
+ position,
+}: {
+ answer: ExtendedRecentUserAnswer;
+ position: 'left' | 'right';
+}) {
+ return (
+
+
+
+
+ Show details
+
+
+
+ Question Details
+
+
+
+ Time taken: {formatTimeTaken(answer.timeTaken)}
+
+
+
+ Date: {format(new Date(answer.createdAt), 'MMM d, yyyy h:mm a')}
+
+
+
+ Difficulty: {answer.question.difficulty || 'N/A'}
+
+
+
+
+ {answer.correctAnswer ? (
+
+ ) : (
+
+ )}
+
+
+ {answer.correctAnswer ? 'Correct answer' : 'Incorrect answer'}
+
+
+
+
+
+
+ );
+}
+
+function QuestionItem({
+ answer,
+ dropdownPosition,
+}: {
+ answer: ExtendedRecentUserAnswer;
+ dropdownPosition: 'left' | 'right';
+}) {
+ return (
+
+
+
+
+ {answer.correctAnswer ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {answer.question.title || answer?.question?.question?.substring(0, 50)}
+
+
+ {formatDistanceToNow(new Date(answer.createdAt), {
+ addSuffix: true,
+ })}
+
+
+
+
+
+
+
+ );
+}
+
+export default function QuestionHistory({
+ className,
+ recentAnswers,
+ dropdownPosition = 'right',
+}: QuestionHistoryProps) {
+ const answeredCount = recentAnswers.length;
+ const correctCount = recentAnswers.filter((answer) => answer.correctAnswer).length;
+
+ return (
+
+
+
+
+
+
+
+ Last 10 questions
+ Question History
+
+
+
+
+
+
+
+
+ In the last 7 days, you have answered {answeredCount} questions
+ {answeredCount > 0 && ` with ${correctCount} correct answers`}.
+ {answeredCount > 0 ? ' Great work!' : ' Start answering to track your progress.'}
+
+
+
+
+
+ {recentAnswers.length === 0 ? (
+
+
No recent questions found.
+
+ Start answering questions
+
+
+ ) : (
+
+ {recentAnswers.map((answer) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/app/statistics/question-tracker.stories.tsx b/src/components/app/statistics/question-tracker.stories.tsx
new file mode 100644
index 000000000..ecd31b31c
--- /dev/null
+++ b/src/components/app/statistics/question-tracker.stories.tsx
@@ -0,0 +1,19 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import QuestionTracker from '@/components/app/statistics/question-tracker';
+
+const meta = {
+ component: QuestionTracker,
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ stats: {},
+ step: 'month',
+ range: '7d',
+ },
+};
diff --git a/src/components/app/statistics/question-tracker.tsx b/src/components/app/statistics/question-tracker.tsx
index abe0e80b3..b29ddf031 100644
--- a/src/components/app/statistics/question-tracker.tsx
+++ b/src/components/app/statistics/question-tracker.tsx
@@ -1,6 +1,6 @@
'use client';
-import { Tracker } from '@/components/ui/tracker';
+import { Tracker } from '@/components/charts/tracker';
import { cn } from '@/lib/utils';
import type { StatsSteps } from '@/types';
diff --git a/src/components/app/statistics/top-tags.stories.tsx b/src/components/app/statistics/top-tags.stories.tsx
new file mode 100644
index 000000000..e38725558
--- /dev/null
+++ b/src/components/app/statistics/top-tags.stories.tsx
@@ -0,0 +1,15 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import TopTags from './top-tags';
+
+const meta = {
+ component: TopTags,
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {},
+};
diff --git a/src/components/app/statistics/top-tags.tsx b/src/components/app/statistics/top-tags.tsx
new file mode 100644
index 000000000..0cfd3f5c4
--- /dev/null
+++ b/src/components/app/statistics/top-tags.tsx
@@ -0,0 +1,3 @@
+export default function TopTags() {
+ return TopTags
;
+}
diff --git a/src/components/app/statistics/total-question-chart.tsx b/src/components/app/statistics/total-question-chart.tsx
deleted file mode 100644
index 10883ad08..000000000
--- a/src/components/app/statistics/total-question-chart.tsx
+++ /dev/null
@@ -1,233 +0,0 @@
-'use client';
-
-import React, { useMemo, useState } from 'react';
-import { TrendingUp, TrendingDown, BarChartIcon, LineChartIcon, Circle } from 'lucide-react';
-import { CartesianGrid, Bar, BarChart, Line, LineChart, XAxis, YAxis } from 'recharts';
-import NumberFlow from '@number-flow/react';
-
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
-import {
- ChartConfig,
- ChartContainer,
- ChartTooltip,
- ChartTooltipContent,
-} from '@/components/ui/chart';
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select';
-import { cn } from '@/lib/utils';
-
-const chartConfig = {
- questions: {
- label: 'Questions',
- color: 'hsl(var(--chart-1))',
- },
-} satisfies ChartConfig;
-
-export interface StatsChartData {
- [key: string]: {
- totalQuestions: number;
- tagCounts: Record;
- tags: string[];
- };
-}
-
-export default function QuestionChart({
- questionData,
- step,
- backgroundColor,
-}: {
- questionData: StatsChartData;
- step: 'day' | 'week' | 'month';
- backgroundColor?: string;
-}) {
- const [chartType, setChartType] = useState<'bar' | 'line'>('bar');
-
- const chartData = useMemo(() => {
- const entries = Object.entries(questionData);
-
- // Sort entries by date - latest first
- entries.sort((a, b) => {
- const [dateA] = a;
- const [dateB] = b;
- return new Date(dateB).getTime() - new Date(dateA).getTime();
- });
-
- // Directly use the keys as they should now be pre-formatted
- return entries.map(([date, data]) => ({
- date: date.split(',')[0],
- questions: data.totalQuestions,
- }));
- }, [questionData]);
-
- // order the chart data by the date. Ensuring that the oldest date is first
- const orderedChartData = chartData.sort((a, b) => {
- return new Date(a.date).getTime() - new Date(b.date).getTime();
- });
-
- const trend = useMemo(() => {
- // if there is less than 2 periods, return 0
- if (orderedChartData.length < 2) {
- return { percentage: 0, isNeutral: true };
- }
-
- // get the first and last period of the chart data
- const firstPeriod = chartData[0];
- const lastPeriod = chartData[chartData.length - 1];
-
- // Handle case where first period has 0 questions
- if (firstPeriod.questions === 0) {
- if (lastPeriod.questions === 0) {
- return { percentage: 0, isNeutral: true };
- }
- // If starting from 0, treat treat as 0 * lastPeriod.questions increase
- return {
- percentage: 100 * lastPeriod.questions,
- isNeutral: false,
- isUp: true,
- };
- }
-
- // calculate the percentage change between the first and last period
- const percentageChange =
- ((lastPeriod.questions - firstPeriod.questions) / firstPeriod.questions) * 100;
-
- return {
- percentage: Math.abs(percentageChange).toFixed(2),
- isNeutral: percentageChange === 0,
- isUp: percentageChange > 0,
- };
- }, [chartData]);
-
- const maxQuestions = useMemo(() => {
- return Math.max(...chartData.map((data) => data.questions));
- }, [chartData]);
-
- const yAxisDomain = useMemo(() => {
- const maxY = Math.ceil(maxQuestions * 1.1);
- const minY = Math.max(
- 0,
- Math.floor(Math.min(...chartData.map((data) => data.questions)) * 0.9)
- );
- return [minY, maxY];
- }, [maxQuestions, chartData]);
-
- const renderChart = () => {
- const ChartComponent = chartType === 'bar' ? BarChart : LineChart;
-
- return (
-
-
- value.split(',')[0]}
- />
- `${value}`}
- width={30}
- tick={{ fill: 'hsl(var(--muted-foreground))' }}
- domain={yAxisDomain}
- />
- } />
- {chartType === 'bar' ? (
-
- ) : (
-
- )}
-
- );
- };
-
- return (
-
-
-
-
Questions Answered
-
-
-
- %
-
- {trend.isUp && !trend.isNeutral ? (
-
- ) : !trend.isNeutral ? (
-
- ) : (
-
- )}
-
-
setChartType(value)}
- >
-
-
-
-
-
-
-
- Bar Chart
-
-
-
-
-
- Line Chart
-
-
-
-
-
-
-
-
- Last {chartData.length} {step}s
-
-
-
-
-
- {renderChart()}
-
-
-
- );
-}
diff --git a/src/components/charts/area-chart.stories.tsx b/src/components/charts/area-chart.stories.tsx
new file mode 100644
index 000000000..7bfab558a
--- /dev/null
+++ b/src/components/charts/area-chart.stories.tsx
@@ -0,0 +1,243 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { AreaChart } from './area-chart';
+
+// Helper function to generate dates for the past n days/weeks/months
+const generateDates = (count: number, step: 'day' | 'week' | 'month'): string[] => {
+ const dates: string[] = [];
+ const now = new Date();
+
+ for (let i = count - 1; i >= 0; i--) {
+ const date = new Date();
+
+ if (step === 'day') {
+ date.setDate(now.getDate() - i);
+ } else if (step === 'week') {
+ date.setDate(now.getDate() - i * 7);
+ } else {
+ date.setMonth(now.getMonth() - i);
+ }
+
+ // Format date as "Apr 15"
+ dates.push(date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }));
+ }
+
+ return dates;
+};
+
+// Create upward trend data
+const createUpwardTrendData = (count: number, startValue: number = 5) => {
+ const dates = generateDates(count, 'day');
+ return dates.map((date, index) => {
+ // Increasing trend with some randomness
+ const value = Math.floor(startValue + index * 3 + Math.random() * 4);
+
+ return {
+ date,
+ value,
+ secondary: Math.floor(value * 0.7),
+ };
+ });
+};
+
+// Create downward trend data
+const createDownwardTrendData = (count: number, startValue: number = 40) => {
+ const dates = generateDates(count, 'week');
+ return dates.map((date, index) => {
+ // Decreasing trend with some randomness
+ const value = Math.max(0, Math.floor(startValue - index * 2 + Math.random() * 3));
+
+ return {
+ date,
+ value,
+ secondary: Math.floor(value * 0.6),
+ };
+ });
+};
+
+// Create fluctuating trend data
+const createFluctuatingTrendData = (count: number, baseline: number = 20) => {
+ const dates = generateDates(count, 'month');
+ return dates.map((date, index) => {
+ // Fluctuating pattern with sine wave
+ const amplitude = 15;
+ const period = count / 3;
+ const value = Math.max(
+ 0,
+ Math.floor(baseline + amplitude * Math.sin((index / period) * Math.PI) + Math.random() * 5)
+ );
+
+ return {
+ date,
+ value,
+ secondary: Math.floor(value * 0.65),
+ };
+ });
+};
+
+// Generate mock data
+const dailyUpwardData = createUpwardTrendData(14);
+const weeklyDownwardData = createDownwardTrendData(10);
+const monthlyFluctuatingData = createFluctuatingTrendData(12);
+
+// Format value for the chart
+const valueFormatter = (value: number) => {
+ return value.toFixed(0);
+};
+
+const ChartWrapper = (props: React.ComponentProps) => {
+ return (
+
+ );
+};
+
+const meta = {
+ title: 'Charts/AreaChart',
+ component: ChartWrapper,
+ parameters: {
+ layout: 'centered',
+ backgrounds: {
+ default: 'dark',
+ values: [{ name: 'dark', value: '#090909' }],
+ },
+ },
+ tags: ['autodocs'],
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const DailyUpwardTrend: Story = {
+ args: {
+ data: dailyUpwardData,
+ index: 'date',
+ categories: ['value'],
+ colors: ['accent'],
+ valueFormatter,
+ showXAxis: true,
+ showYAxis: true,
+ showGridLines: true,
+ yAxisWidth: 40,
+ showLegend: false,
+ showTooltip: true,
+ fill: 'gradient',
+ connectNulls: true,
+ autoMinValue: true,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Shows an upward trend in daily data over the past 14 days using a gradient fill.',
+ },
+ },
+ },
+};
+
+export const WeeklyDownwardTrend: Story = {
+ args: {
+ data: weeklyDownwardData,
+ index: 'date',
+ categories: ['value'],
+ colors: ['pink'],
+ valueFormatter,
+ showXAxis: true,
+ showYAxis: true,
+ showGridLines: true,
+ yAxisWidth: 40,
+ showLegend: false,
+ showTooltip: true,
+ fill: 'solid',
+ connectNulls: true,
+ autoMinValue: false,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Shows a downward trend in weekly data over the past 10 weeks using a solid fill.',
+ },
+ },
+ },
+};
+
+export const MonthlyFluctuating: Story = {
+ args: {
+ data: monthlyFluctuatingData,
+ index: 'date',
+ categories: ['value'],
+ colors: ['violet'],
+ valueFormatter,
+ showXAxis: true,
+ showYAxis: true,
+ showGridLines: true,
+ yAxisWidth: 40,
+ showLegend: false,
+ showTooltip: true,
+ fill: 'gradient',
+ connectNulls: true,
+ autoMinValue: true,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Shows a fluctuating pattern in monthly data over the past 12 months.',
+ },
+ },
+ },
+};
+
+export const MultipleCategories: Story = {
+ args: {
+ data: dailyUpwardData,
+ index: 'date',
+ categories: ['value', 'secondary'],
+ colors: ['blue', 'emerald'],
+ valueFormatter,
+ showXAxis: true,
+ showYAxis: true,
+ showGridLines: true,
+ yAxisWidth: 40,
+ showLegend: true,
+ showTooltip: true,
+ fill: 'gradient',
+ connectNulls: true,
+ autoMinValue: true,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Shows multiple data series to compare primary and secondary values.',
+ },
+ },
+ },
+};
+
+export const BarLikeStyle: Story = {
+ args: {
+ data: weeklyDownwardData,
+ index: 'date',
+ categories: ['value'],
+ colors: ['amber'],
+ valueFormatter,
+ showXAxis: true,
+ showYAxis: true,
+ showGridLines: true,
+ yAxisWidth: 40,
+ showLegend: false,
+ showTooltip: true,
+ fill: 'solid',
+ tickGap: 40,
+ connectNulls: true,
+ autoMinValue: false,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Shows a bar-like appearance with solid fill and increased spacing between data points.',
+ },
+ },
+ },
+};
diff --git a/src/components/charts/area-chart.tsx b/src/components/charts/area-chart.tsx
new file mode 100644
index 000000000..03de87e8a
--- /dev/null
+++ b/src/components/charts/area-chart.tsx
@@ -0,0 +1,916 @@
+// Tremor AreaChart [v0.3.1]
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+'use client';
+
+import React from 'react';
+import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react';
+import {
+ Area,
+ CartesianGrid,
+ Dot,
+ Label,
+ Line,
+ AreaChart as RechartsAreaChart,
+ Legend as RechartsLegend,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from 'recharts';
+import { AxisDomain } from 'recharts/types/util/types';
+
+import {
+ AvailableChartColors,
+ AvailableChartColorsKeys,
+ constructCategoryColors,
+ getColorClassName,
+ getYAxisDomain,
+ hasOnlyOneValueForKey,
+} from '@/lib/chart-utils';
+import { useOnWindowResize } from '@/hooks/use-on-window-resize';
+import { cn } from '@/lib/utils';
+
+//#region Legend
+
+interface LegendItemProps {
+ name: string;
+ color: AvailableChartColorsKeys;
+ onClick?: (name: string, color: AvailableChartColorsKeys) => void;
+ activeLegend?: string;
+}
+
+const LegendItem = ({ name, color, onClick, activeLegend }: LegendItemProps) => {
+ const hasOnValueChange = !!onClick;
+ return (
+ {
+ e.stopPropagation();
+ onClick?.(name, color);
+ }}
+ >
+
+
+ {name}
+
+
+ );
+};
+
+interface ScrollButtonProps {
+ icon: React.ElementType;
+ onClick?: () => void;
+ disabled?: boolean;
+}
+
+const ScrollButton = ({ icon, onClick, disabled }: ScrollButtonProps) => {
+ const Icon = icon;
+ const [isPressed, setIsPressed] = React.useState(false);
+ const intervalRef = React.useRef(null);
+
+ React.useEffect(() => {
+ if (isPressed) {
+ intervalRef.current = setInterval(() => {
+ onClick?.();
+ }, 300);
+ } else {
+ clearInterval(intervalRef.current as NodeJS.Timeout);
+ }
+ return () => clearInterval(intervalRef.current as NodeJS.Timeout);
+ }, [isPressed, onClick]);
+
+ React.useEffect(() => {
+ if (disabled) {
+ clearInterval(intervalRef.current as NodeJS.Timeout);
+ setIsPressed(false);
+ }
+ }, [disabled]);
+
+ return (
+ {
+ e.stopPropagation();
+ onClick?.();
+ }}
+ onMouseDown={(e) => {
+ e.stopPropagation();
+ setIsPressed(true);
+ }}
+ onMouseUp={(e) => {
+ e.stopPropagation();
+ setIsPressed(false);
+ }}
+ >
+
+
+ );
+};
+
+interface LegendProps extends React.OlHTMLAttributes {
+ categories: string[];
+ colors?: AvailableChartColorsKeys[];
+ onClickLegendItem?: (category: string, color: string) => void;
+ activeLegend?: string;
+ enableLegendSlider?: boolean;
+}
+
+type HasScrollProps = {
+ left: boolean;
+ right: boolean;
+};
+
+const Legend = React.forwardRef((props, ref) => {
+ const {
+ categories,
+ colors = AvailableChartColors,
+ className,
+ onClickLegendItem,
+ activeLegend,
+ enableLegendSlider = false,
+ ...other
+ } = props;
+ const scrollableRef = React.useRef(null);
+ const scrollButtonsRef = React.useRef(null);
+ const [hasScroll, setHasScroll] = React.useState(null);
+ const [isKeyDowned, setIsKeyDowned] = React.useState(null);
+ const intervalRef = React.useRef(null);
+
+ const checkScroll = React.useCallback(() => {
+ const scrollable = scrollableRef?.current;
+ if (!scrollable) return;
+
+ const hasLeftScroll = scrollable.scrollLeft > 0;
+ const hasRightScroll = scrollable.scrollWidth - scrollable.clientWidth > scrollable.scrollLeft;
+
+ setHasScroll({ left: hasLeftScroll, right: hasRightScroll });
+ }, [setHasScroll]);
+
+ const scrollToTest = React.useCallback(
+ (direction: 'left' | 'right') => {
+ const element = scrollableRef?.current;
+ const scrollButtons = scrollButtonsRef?.current;
+ const scrollButtonsWith = scrollButtons?.clientWidth ?? 0;
+ const width = element?.clientWidth ?? 0;
+
+ if (element && enableLegendSlider) {
+ element.scrollTo({
+ left:
+ direction === 'left'
+ ? element.scrollLeft - width + scrollButtonsWith
+ : element.scrollLeft + width - scrollButtonsWith,
+ behavior: 'smooth',
+ });
+ setTimeout(() => {
+ checkScroll();
+ }, 400);
+ }
+ },
+ [enableLegendSlider, checkScroll]
+ );
+
+ React.useEffect(() => {
+ const keyDownHandler = (key: string) => {
+ if (key === 'ArrowLeft') {
+ scrollToTest('left');
+ } else if (key === 'ArrowRight') {
+ scrollToTest('right');
+ }
+ };
+ if (isKeyDowned) {
+ keyDownHandler(isKeyDowned);
+ intervalRef.current = setInterval(() => {
+ keyDownHandler(isKeyDowned);
+ }, 300);
+ } else {
+ clearInterval(intervalRef.current as NodeJS.Timeout);
+ }
+ return () => clearInterval(intervalRef.current as NodeJS.Timeout);
+ }, [isKeyDowned, scrollToTest]);
+
+ const keyDown = (e: KeyboardEvent) => {
+ e.stopPropagation();
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
+ e.preventDefault();
+ setIsKeyDowned(e.key);
+ }
+ };
+ const keyUp = (e: KeyboardEvent) => {
+ e.stopPropagation();
+ setIsKeyDowned(null);
+ };
+
+ React.useEffect(() => {
+ const scrollable = scrollableRef?.current;
+ if (enableLegendSlider) {
+ checkScroll();
+ scrollable?.addEventListener('keydown', keyDown);
+ scrollable?.addEventListener('keyup', keyUp);
+ }
+
+ return () => {
+ scrollable?.removeEventListener('keydown', keyDown);
+ scrollable?.removeEventListener('keyup', keyUp);
+ };
+ }, [checkScroll, enableLegendSlider]);
+
+ return (
+
+
+ {categories.map((category, index) => (
+
+ ))}
+
+ {enableLegendSlider && (hasScroll?.right || hasScroll?.left) ? (
+ <>
+
+ {
+ setIsKeyDowned(null);
+ scrollToTest('left');
+ }}
+ disabled={!hasScroll?.left}
+ />
+ {
+ setIsKeyDowned(null);
+ scrollToTest('right');
+ }}
+ disabled={!hasScroll?.right}
+ />
+
+ >
+ ) : null}
+
+ );
+});
+
+Legend.displayName = 'Legend';
+
+const ChartLegend = (
+ { payload }: any,
+ categoryColors: Map,
+ setLegendHeight: React.Dispatch>,
+ activeLegend: string | undefined,
+ onClick?: (category: string, color: string) => void,
+ enableLegendSlider?: boolean,
+ legendPosition?: 'left' | 'center' | 'right',
+ yAxisWidth?: number
+) => {
+ const legendRef = React.useRef(null);
+
+ useOnWindowResize(() => {
+ const calculateHeight = (height: number | undefined) => (height ? Number(height) + 15 : 60);
+ setLegendHeight(calculateHeight(legendRef.current?.clientHeight));
+ });
+
+ const legendPayload = payload.filter((item: any) => item.type !== 'none');
+
+ const paddingLeft = legendPosition === 'left' && yAxisWidth ? yAxisWidth - 8 : 0;
+
+ return (
+
+ entry.value)}
+ colors={legendPayload.map((entry: any) => categoryColors.get(entry.value))}
+ onClickLegendItem={onClick}
+ activeLegend={activeLegend}
+ enableLegendSlider={enableLegendSlider}
+ />
+
+ );
+};
+
+//#region Tooltip
+
+type TooltipProps = Pick;
+
+type PayloadItem = {
+ category: string;
+ value: number;
+ index: string;
+ color: AvailableChartColorsKeys;
+ type?: string;
+ payload: any;
+};
+
+interface ChartTooltipProps {
+ active: boolean | undefined;
+ payload: PayloadItem[];
+ label: string;
+ valueFormatter: (value: number) => string;
+}
+
+const ChartTooltip = ({ active, payload, label, valueFormatter }: ChartTooltipProps) => {
+ if (active && payload && payload.length) {
+ return (
+
+
+
+ {payload.map(({ value, category, color }, index) => (
+
+
+
+ {valueFormatter(value)}
+
+
+ ))}
+
+
+ );
+ }
+ return null;
+};
+
+//#region AreaChart
+
+interface ActiveDot {
+ index?: number;
+ dataKey?: string;
+}
+
+type BaseEventProps = {
+ eventType: 'dot' | 'category';
+ categoryClicked: string;
+ [key: string]: number | string;
+};
+
+type AreaChartEventProps = BaseEventProps | null | undefined;
+
+interface AreaChartProps extends React.HTMLAttributes {
+ data: Record[];
+ index: string;
+ categories: string[];
+ colors?: AvailableChartColorsKeys[];
+ valueFormatter?: (value: number) => string;
+ startEndOnly?: boolean;
+ showXAxis?: boolean;
+ showYAxis?: boolean;
+ showGridLines?: boolean;
+ yAxisWidth?: number;
+ intervalType?: 'preserveStartEnd' | 'equidistantPreserveStart';
+ showTooltip?: boolean;
+ showLegend?: boolean;
+ autoMinValue?: boolean;
+ minValue?: number;
+ maxValue?: number;
+ allowDecimals?: boolean;
+ onValueChange?: (value: AreaChartEventProps) => void;
+ enableLegendSlider?: boolean;
+ tickGap?: number;
+ connectNulls?: boolean;
+ xAxisLabel?: string;
+ yAxisLabel?: string;
+ type?: 'default' | 'stacked' | 'percent';
+ legendPosition?: 'left' | 'center' | 'right';
+ fill?: 'gradient' | 'solid' | 'none';
+ tooltipCallback?: (tooltipCallbackContent: TooltipProps) => void;
+ customTooltip?: React.ComponentType;
+}
+
+const AreaChart = React.forwardRef((props, ref) => {
+ const {
+ data = [],
+ categories = [],
+ index,
+ colors = AvailableChartColors,
+ valueFormatter = (value: number) => value.toString(),
+ startEndOnly = false,
+ showXAxis = true,
+ showYAxis = true,
+ showGridLines = true,
+ yAxisWidth = 56,
+ intervalType = 'equidistantPreserveStart',
+ showTooltip = true,
+ showLegend = true,
+ autoMinValue = false,
+ minValue,
+ maxValue,
+ allowDecimals = true,
+ connectNulls = false,
+ className,
+ onValueChange,
+ enableLegendSlider = false,
+ tickGap = 5,
+ xAxisLabel,
+ yAxisLabel,
+ type = 'default',
+ legendPosition = 'right',
+ fill = 'gradient',
+ tooltipCallback,
+ customTooltip,
+ ...other
+ } = props;
+ const CustomTooltip = customTooltip;
+ const paddingValue = (!showXAxis && !showYAxis) || (startEndOnly && !showYAxis) ? 0 : 20;
+ const [legendHeight, setLegendHeight] = React.useState(60);
+ const [activeDot, setActiveDot] = React.useState(undefined);
+ const [activeLegend, setActiveLegend] = React.useState(undefined);
+ const categoryColors = constructCategoryColors(categories, colors);
+
+ const yAxisDomain = getYAxisDomain(autoMinValue, minValue, maxValue);
+ const hasOnValueChange = !!onValueChange;
+ const stacked = type === 'stacked' || type === 'percent';
+ const areaId = React.useId();
+
+ const prevActiveRef = React.useRef(undefined);
+ const prevLabelRef = React.useRef(undefined);
+
+ const getFillContent = ({
+ fillType,
+ activeDot,
+ activeLegend,
+ category,
+ }: {
+ fillType: AreaChartProps['fill'];
+ activeDot: ActiveDot | undefined;
+ activeLegend: string | undefined;
+ category: string;
+ }) => {
+ const stopOpacity = activeDot || (activeLegend && activeLegend !== category) ? 0.1 : 0.3;
+
+ switch (fillType) {
+ case 'none':
+ return ;
+ case 'gradient':
+ return (
+ <>
+
+
+ >
+ );
+ case 'solid':
+ default:
+ return ;
+ }
+ };
+
+ function valueToPercent(value: number) {
+ return `${(value * 100).toFixed(0)}%`;
+ }
+
+ function onDotClick(itemData: any, event: React.MouseEvent) {
+ event.stopPropagation();
+
+ if (!hasOnValueChange) return;
+ if (
+ (itemData.index === activeDot?.index && itemData.dataKey === activeDot?.dataKey) ||
+ (hasOnlyOneValueForKey(data, itemData.dataKey) &&
+ activeLegend &&
+ activeLegend === itemData.dataKey)
+ ) {
+ setActiveLegend(undefined);
+ setActiveDot(undefined);
+ onValueChange?.(null);
+ } else {
+ setActiveLegend(itemData.dataKey);
+ setActiveDot({
+ index: itemData.index,
+ dataKey: itemData.dataKey,
+ });
+ onValueChange?.({
+ eventType: 'dot',
+ categoryClicked: itemData.dataKey,
+ ...itemData.payload,
+ });
+ }
+ }
+
+ function onCategoryClick(dataKey: string) {
+ if (!hasOnValueChange) return;
+ if (
+ (dataKey === activeLegend && !activeDot) ||
+ (hasOnlyOneValueForKey(data, dataKey) && activeDot && activeDot.dataKey === dataKey)
+ ) {
+ setActiveLegend(undefined);
+ onValueChange?.(null);
+ } else {
+ setActiveLegend(dataKey);
+ onValueChange?.({
+ eventType: 'category',
+ categoryClicked: dataKey,
+ });
+ }
+ setActiveDot(undefined);
+ }
+
+ return (
+
+
+ {
+ setActiveDot(undefined);
+ setActiveLegend(undefined);
+ onValueChange?.(null);
+ }
+ : undefined
+ }
+ margin={{
+ bottom: xAxisLabel ? 30 : undefined,
+ left: yAxisLabel ? 20 : undefined,
+ right: yAxisLabel ? 5 : undefined,
+ top: 5,
+ }}
+ stackOffset={type === 'percent' ? 'expand' : undefined}
+ >
+ {showGridLines ? (
+
+ ) : null}
+
+ {xAxisLabel && (
+
+ {xAxisLabel}
+
+ )}
+
+
+ {yAxisLabel && (
+
+ {yAxisLabel}
+
+ )}
+
+ {
+ const cleanPayload: TooltipProps['payload'] = payload
+ ? payload.map((item: any) => ({
+ category: item.dataKey,
+ value: item.value,
+ index: item.payload[index],
+ color: categoryColors.get(item.dataKey) as AvailableChartColorsKeys,
+ type: item.type,
+ payload: item.payload,
+ }))
+ : [];
+
+ if (
+ tooltipCallback &&
+ (active !== prevActiveRef.current || label !== prevLabelRef.current)
+ ) {
+ tooltipCallback({ active, payload: cleanPayload, label });
+ prevActiveRef.current = active;
+ prevLabelRef.current = label;
+ }
+
+ return showTooltip && active ? (
+ CustomTooltip ? (
+
+ ) : (
+
+ )
+ ) : null;
+ }}
+ />
+
+ {showLegend ? (
+
+ ChartLegend(
+ { payload },
+ categoryColors,
+ setLegendHeight,
+ activeLegend,
+ hasOnValueChange
+ ? (clickedLegendItem: string) => onCategoryClick(clickedLegendItem)
+ : undefined,
+ enableLegendSlider,
+ legendPosition,
+ yAxisWidth
+ )
+ }
+ />
+ ) : null}
+ {categories.map((category) => {
+ const categoryId = `${areaId}-${category.replace(/[^a-zA-Z0-9]/g, '')}`;
+ return (
+
+
+
+ {getFillContent({
+ fillType: fill,
+ activeDot: activeDot,
+ activeLegend: activeLegend,
+ category: category,
+ })}
+
+
+ {
+ const {
+ cx: cxCoord,
+ cy: cyCoord,
+ stroke,
+ strokeLinecap,
+ strokeLinejoin,
+ strokeWidth,
+ dataKey,
+ } = props;
+ return (
+ onDotClick(props, event)}
+ />
+ );
+ }}
+ dot={(props: any) => {
+ const {
+ stroke,
+ strokeLinecap,
+ strokeLinejoin,
+ strokeWidth,
+ cx: cxCoord,
+ cy: cyCoord,
+ dataKey,
+ index,
+ } = props;
+
+ if (
+ (hasOnlyOneValueForKey(data, category) &&
+ !(activeDot || (activeLegend && activeLegend !== category))) ||
+ (activeDot?.index === index && activeDot?.dataKey === category)
+ ) {
+ return (
+
+ );
+ }
+ return ;
+ }}
+ key={category}
+ name={category}
+ type="linear"
+ dataKey={category}
+ stroke=""
+ strokeWidth={2}
+ strokeLinejoin="round"
+ strokeLinecap="round"
+ isAnimationActive={false}
+ connectNulls={connectNulls}
+ stackId={stacked ? 'stack' : undefined}
+ fill={`url(#${categoryId})`}
+ />
+
+ );
+ })}
+ {/* hidden lines to increase clickable target area */}
+ {onValueChange
+ ? categories.map((category) => (
+ {
+ event.stopPropagation();
+ const { name } = props;
+ onCategoryClick(name);
+ }}
+ />
+ ))
+ : null}
+
+
+
+ );
+});
+
+AreaChart.displayName = 'AreaChart';
+
+export { AreaChart, type AreaChartEventProps, type TooltipProps };
diff --git a/src/components/charts/bar-list-chart.stories.tsx b/src/components/charts/bar-list-chart.stories.tsx
new file mode 100644
index 000000000..f9cc88db0
--- /dev/null
+++ b/src/components/charts/bar-list-chart.stories.tsx
@@ -0,0 +1,188 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { BarList } from './bar-list-chart';
+
+// Define the Bar item type
+type BarItem = {
+ name: string;
+ value: number;
+ href?: string;
+ key?: string;
+};
+
+// Mock data for programming languages popularity
+const programmingLanguagesData: BarItem[] = [
+ { name: 'JavaScript', value: 69.7 },
+ { name: 'Python', value: 68.0 },
+ { name: 'TypeScript', value: 42.3 },
+ { name: 'Java', value: 35.4 },
+ { name: 'C#', value: 30.1 },
+ { name: 'PHP', value: 20.7 },
+ { name: 'C++', value: 20.2 },
+ { name: 'Go', value: 14.5 },
+ { name: 'Rust', value: 12.2 },
+ { name: 'Kotlin', value: 9.1 },
+];
+
+// Mock data for tech stack usage with links
+const techStackData: BarItem[] = [
+ { name: 'React', value: 45.3, href: 'https://reactjs.org' },
+ { name: 'Node.js', value: 39.1, href: 'https://nodejs.org' },
+ { name: 'Next.js', value: 30.5, href: 'https://nextjs.org' },
+ { name: 'PostgreSQL', value: 27.8, href: 'https://postgresql.org' },
+ { name: 'MongoDB', value: 25.2, href: 'https://mongodb.com' },
+ { name: 'Express', value: 23.9, href: 'https://expressjs.com' },
+ { name: 'Redux', value: 20.1, href: 'https://redux.js.org' },
+ { name: 'GraphQL', value: 16.7, href: 'https://graphql.org' },
+ { name: 'TypeORM', value: 9.3, href: 'https://typeorm.io' },
+ { name: 'Prisma', value: 8.7, href: 'https://prisma.io' },
+];
+
+// Mock data for website analytics
+const analyticsData: BarItem[] = [
+ { name: 'Blog Posts', value: 1428 },
+ { name: 'Landing Pages', value: 976 },
+ { name: 'Documentation', value: 689 },
+ { name: 'Tutorials', value: 572 },
+ { name: 'API Reference', value: 412 },
+];
+
+// Mock data with very disparate values
+const disparateData: BarItem[] = [
+ { name: 'Category A', value: 1250 },
+ { name: 'Category B', value: 800 },
+ { name: 'Category C', value: 275 },
+ { name: 'Category D', value: 120 },
+ { name: 'Category E', value: 2 },
+];
+
+// Mock data for benchmarks
+const benchmarkData: BarItem[] = [
+ { name: 'Project Alpha', value: 3.42, key: 'alpha' },
+ { name: 'Project Beta', value: 2.78, key: 'beta' },
+ { name: 'Project Gamma', value: 5.14, key: 'gamma' },
+ { name: 'Project Delta', value: 1.23, key: 'delta' },
+];
+
+// Mock data for market share
+const marketShareData: BarItem[] = [
+ { name: 'Company A', value: 37.5 },
+ { name: 'Company B', value: 28.3 },
+ { name: 'Company C', value: 15.2 },
+ { name: 'Company D', value: 10.8 },
+ { name: 'Others', value: 8.2 },
+];
+
+// Mock data for revenue
+const revenueData: BarItem[] = [
+ { name: 'Q1 2023', value: 1250000 },
+ { name: 'Q2 2023', value: 1450000 },
+ { name: 'Q3 2023', value: 1320000 },
+ { name: 'Q4 2023', value: 1820000 },
+];
+
+const meta: Meta = {
+ title: 'Charts/BarList',
+ component: BarList,
+ parameters: {
+ layout: 'centered',
+ },
+ tags: ['autodocs'],
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ data: programmingLanguagesData,
+ valueFormatter: (value: number) => `${value}%`,
+ },
+};
+
+export const WithLinks: Story = {
+ args: {
+ data: techStackData,
+ valueFormatter: (value: number) => `${value}%`,
+ },
+};
+
+export const Analytics: Story = {
+ args: {
+ data: analyticsData,
+ valueFormatter: (value: number) => value.toLocaleString(),
+ },
+};
+
+export const AscendingOrder: Story = {
+ args: {
+ data: programmingLanguagesData,
+ valueFormatter: (value: number) => `${value}%`,
+ sortOrder: 'ascending',
+ },
+};
+
+export const NoSorting: Story = {
+ args: {
+ data: programmingLanguagesData,
+ valueFormatter: (value: number) => `${value}%`,
+ sortOrder: 'none',
+ },
+};
+
+export const DisparateValues: Story = {
+ args: {
+ data: disparateData,
+ valueFormatter: (value: number) => value.toLocaleString(),
+ },
+};
+
+export const WithAnimation: Story = {
+ args: {
+ data: programmingLanguagesData,
+ valueFormatter: (value: number) => `${value}%`,
+ showAnimation: true,
+ },
+};
+
+export const Interactive: Story = {
+ args: {
+ data: programmingLanguagesData,
+ valueFormatter: (value: number) => `${value}%`,
+ onValueChange: (item: BarItem) => console.log(`Selected: ${item.name} - ${item.value}%`),
+ },
+};
+
+export const Benchmarks: Story = {
+ args: {
+ data: benchmarkData,
+ valueFormatter: (value: number) => `${value.toFixed(2)} ms`,
+ },
+};
+
+export const MarketShare: Story = {
+ args: {
+ data: marketShareData,
+ valueFormatter: (value: number) => `${value}%`,
+ },
+};
+
+export const Revenue: Story = {
+ args: {
+ data: revenueData,
+ valueFormatter: (value: number) =>
+ new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: 'USD',
+ maximumFractionDigits: 0,
+ }).format(value),
+ },
+};
diff --git a/src/components/charts/bar-list-chart.tsx b/src/components/charts/bar-list-chart.tsx
new file mode 100644
index 000000000..c0d23c2d2
--- /dev/null
+++ b/src/components/charts/bar-list-chart.tsx
@@ -0,0 +1,169 @@
+// Tremor BarList [v0.1.1]
+
+import React from 'react';
+
+import { cn as cx, focusRing } from '@/lib/utils';
+import { ChevronRight } from 'lucide-react';
+
+type Bar = T & {
+ key?: string;
+ href?: string;
+ value: number;
+ name: string;
+};
+
+interface BarListProps extends React.HTMLAttributes {
+ data: Bar[];
+ valueFormatter?: (value: number) => string;
+ showAnimation?: boolean;
+ onValueChange?: (payload: Bar) => void;
+ sortOrder?: 'ascending' | 'descending' | 'none';
+ barColor?: string;
+}
+
+function BarListInner(
+ {
+ data = [],
+ valueFormatter = (value) => value.toString(),
+ showAnimation = false,
+ onValueChange,
+ sortOrder = 'descending',
+ className,
+ barColor = 'bg-accent',
+ ...props
+ }: BarListProps,
+ forwardedRef: React.ForwardedRef
+) {
+ const Component = onValueChange ? 'button' : 'div';
+ const sortedData = React.useMemo(() => {
+ if (sortOrder === 'none') {
+ return data;
+ }
+ return [...data].sort((a, b) => {
+ return sortOrder === 'ascending' ? a.value - b.value : b.value - a.value;
+ });
+ }, [data, sortOrder]);
+
+ const widths = React.useMemo(() => {
+ const maxValue = Math.max(...sortedData.map((item) => item.value), 0);
+ return sortedData.map((item) =>
+ item.value === 0 ? 0 : Math.max((item.value / maxValue) * 100, 2)
+ );
+ }, [sortedData]);
+
+ const rowHeight = 'h-8';
+
+ return (
+
+
+ {sortedData.map((item, index) => (
+
{
+ onValueChange?.(item);
+ }}
+ className={cx(
+ // base
+ 'group w-full rounded',
+ // focus
+ onValueChange
+ ? [
+ '!-m-0 cursor-pointer',
+ // hover
+ 'bg-gray-900',
+ ]
+ : ''
+ )}
+ >
+
+
+ ))}
+
+
+ {sortedData.map((item, index) => (
+
+
+ {valueFormatter(item.value)}
+
+
+ ))}
+
+
+ );
+}
+
+BarListInner.displayName = 'BarList';
+
+const BarList = React.forwardRef(BarListInner) as (
+ p: BarListProps & { ref?: React.ForwardedRef }
+) => ReturnType;
+
+export { BarList, type BarListProps };
diff --git a/src/components/ui/chart.tsx b/src/components/charts/chart.tsx
similarity index 100%
rename from src/components/ui/chart.tsx
rename to src/components/charts/chart.tsx
diff --git a/src/components/charts/line-chart.stories.tsx b/src/components/charts/line-chart.stories.tsx
new file mode 100644
index 000000000..a9e418dff
--- /dev/null
+++ b/src/components/charts/line-chart.stories.tsx
@@ -0,0 +1,138 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { LineChart } from './line-chart';
+import Tooltip from './tooltip';
+
+// Generate mock data for the past 7 days
+const generateMockData = () => {
+ const today = new Date();
+ const data = [];
+
+ for (let i = 6; i >= 0; i--) {
+ const date = new Date(today);
+ date.setDate(today.getDate() - i);
+
+ // Format date as YYYY-MM-DD
+ const formattedDate = date.toISOString().split('T')[0];
+
+ // Generate a random value between 10 and 100
+ const questions = Math.floor(Math.random() * 91) + 10;
+
+ data.push({
+ date: formattedDate,
+ questions: questions,
+ });
+ }
+
+ return data;
+};
+
+// Mock data with a clear trend
+const mockTrendData = [
+ { date: '2023-06-01', questions: 15 },
+ { date: '2023-06-02', questions: 25 },
+ { date: '2023-06-03', questions: 32 },
+ { date: '2023-06-04', questions: 40 },
+ { date: '2023-06-05', questions: 52 },
+ { date: '2023-06-06', questions: 58 },
+ { date: '2023-06-07', questions: 65 },
+];
+
+// Mock data with fluctuations
+const mockFluctuatingData = [
+ { date: '2023-06-01', questions: 45 },
+ { date: '2023-06-02', questions: 30 },
+ { date: '2023-06-03', questions: 60 },
+ { date: '2023-06-04', questions: 25 },
+ { date: '2023-06-05', questions: 70 },
+ { date: '2023-06-06', questions: 40 },
+ { date: '2023-06-07', questions: 55 },
+];
+
+// Generate dynamic data for each story render
+const dynamicData = generateMockData();
+
+const meta: Meta = {
+ title: 'Charts/LineChart',
+ component: LineChart,
+ parameters: {
+ layout: 'centered',
+ },
+ tags: ['autodocs'],
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ data: dynamicData,
+ index: 'date',
+ categories: ['questions'],
+ colors: ['blue'],
+ valueFormatter: (value: number) => value.toString(),
+ showXAxis: true,
+ showYAxis: true,
+ showGridLines: true,
+ showLegend: false,
+ showTooltip: true,
+ customTooltip: (props) => ,
+ tickGap: 20,
+ connectNulls: true,
+ autoMinValue: true,
+ },
+};
+
+export const UpwardTrend: Story = {
+ args: {
+ ...Default.args,
+ data: mockTrendData,
+ },
+};
+
+export const Fluctuating: Story = {
+ args: {
+ ...Default.args,
+ data: mockFluctuatingData,
+ },
+};
+
+export const MultipleSeries: Story = {
+ args: {
+ ...Default.args,
+ data: dynamicData.map((item) => ({
+ ...item,
+ completedQuestions: Math.floor(item.questions * 0.7),
+ })),
+ categories: ['questions', 'completedQuestions'],
+ colors: ['cyan', 'emerald'],
+ showLegend: true,
+ },
+};
+
+export const NoGrid: Story = {
+ args: {
+ ...Default.args,
+ showGridLines: false,
+ },
+};
+
+export const CustomColors: Story = {
+ args: {
+ ...Default.args,
+ colors: ['blue', 'pink', 'amber'],
+ },
+};
+
+export const AccentColor: Story = {
+ args: {
+ ...Default.args,
+ colors: ['accent'],
+ },
+};
diff --git a/src/components/charts/line-chart.tsx b/src/components/charts/line-chart.tsx
new file mode 100644
index 000000000..0aaee2f59
--- /dev/null
+++ b/src/components/charts/line-chart.tsx
@@ -0,0 +1,866 @@
+// Tremor LineChart [v0.3.2]
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+'use client';
+
+import React from 'react';
+import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react';
+import {
+ CartesianGrid,
+ Dot,
+ Label,
+ Line,
+ Legend as RechartsLegend,
+ LineChart as RechartsLineChart,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from 'recharts';
+import { AxisDomain } from 'recharts/types/util/types';
+
+import {
+ AvailableChartColors,
+ AvailableChartColorsKeys,
+ constructCategoryColors,
+ getColorClassName,
+ getYAxisDomain,
+ hasOnlyOneValueForKey,
+} from '@/lib/chart-utils';
+import { useOnWindowResize } from '@/hooks/use-on-window-resize';
+import { cn as cx } from '@/lib/utils';
+
+// Helper function to get the actual color value
+const getStrokeColor = (color: AvailableChartColorsKeys): string => {
+ const colorMap: Record = {
+ blue: '#3b82f6',
+ emerald: '#10b981',
+ violet: '#8b5cf6',
+ amber: '#f59e0b',
+ gray: '#6b7280',
+ cyan: '#06b6d4',
+ pink: '#ec4899',
+ lime: '#84cc16',
+ fuchsia: '#d946ef',
+ accent: '#5b61d6',
+ };
+
+ return colorMap[color] || '#3b82f6'; // Default to blue if color not found
+};
+
+//#region Legend
+
+interface LegendItemProps {
+ name: string;
+ color: AvailableChartColorsKeys;
+ onClick?: (name: string, color: AvailableChartColorsKeys) => void;
+ activeLegend?: string;
+}
+
+const LegendItem = ({ name, color, onClick, activeLegend }: LegendItemProps) => {
+ const hasOnValueChange = !!onClick;
+ return (
+ {
+ e.stopPropagation();
+ onClick?.(name, color);
+ }}
+ >
+
+
+ {name}
+
+
+ );
+};
+
+interface ScrollButtonProps {
+ icon: React.ElementType;
+ onClick?: () => void;
+ disabled?: boolean;
+}
+
+const ScrollButton = ({ icon, onClick, disabled }: ScrollButtonProps) => {
+ const Icon = icon;
+ const [isPressed, setIsPressed] = React.useState(false);
+ const intervalRef = React.useRef(null);
+
+ React.useEffect(() => {
+ if (isPressed) {
+ intervalRef.current = setInterval(() => {
+ onClick?.();
+ }, 300);
+ } else {
+ clearInterval(intervalRef.current as NodeJS.Timeout);
+ }
+ return () => clearInterval(intervalRef.current as NodeJS.Timeout);
+ }, [isPressed, onClick]);
+
+ React.useEffect(() => {
+ if (disabled) {
+ clearInterval(intervalRef.current as NodeJS.Timeout);
+ setIsPressed(false);
+ }
+ }, [disabled]);
+
+ return (
+ {
+ e.stopPropagation();
+ onClick?.();
+ }}
+ onMouseDown={(e) => {
+ e.stopPropagation();
+ setIsPressed(true);
+ }}
+ onMouseUp={(e) => {
+ e.stopPropagation();
+ setIsPressed(false);
+ }}
+ >
+
+
+ );
+};
+
+interface LegendProps extends React.OlHTMLAttributes {
+ categories: string[];
+ colors?: AvailableChartColorsKeys[];
+ onClickLegendItem?: (category: string, color: string) => void;
+ activeLegend?: string;
+ enableLegendSlider?: boolean;
+}
+
+type HasScrollProps = {
+ left: boolean;
+ right: boolean;
+};
+
+const Legend = React.forwardRef((props, ref) => {
+ const {
+ categories,
+ colors = AvailableChartColors,
+ className,
+ onClickLegendItem,
+ activeLegend,
+ enableLegendSlider = false,
+ ...other
+ } = props;
+ const scrollableRef = React.useRef(null);
+ const scrollButtonsRef = React.useRef(null);
+ const [hasScroll, setHasScroll] = React.useState(null);
+ const [isKeyDowned, setIsKeyDowned] = React.useState(null);
+ const intervalRef = React.useRef(null);
+
+ const checkScroll = React.useCallback(() => {
+ const scrollable = scrollableRef?.current;
+ if (!scrollable) return;
+
+ const hasLeftScroll = scrollable.scrollLeft > 0;
+ const hasRightScroll = scrollable.scrollWidth - scrollable.clientWidth > scrollable.scrollLeft;
+
+ setHasScroll({ left: hasLeftScroll, right: hasRightScroll });
+ }, [setHasScroll]);
+
+ const scrollToTest = React.useCallback(
+ (direction: 'left' | 'right') => {
+ const element = scrollableRef?.current;
+ const scrollButtons = scrollButtonsRef?.current;
+ const scrollButtonsWith = scrollButtons?.clientWidth ?? 0;
+ const width = element?.clientWidth ?? 0;
+
+ if (element && enableLegendSlider) {
+ element.scrollTo({
+ left:
+ direction === 'left'
+ ? element.scrollLeft - width + scrollButtonsWith
+ : element.scrollLeft + width - scrollButtonsWith,
+ behavior: 'smooth',
+ });
+ setTimeout(() => {
+ checkScroll();
+ }, 400);
+ }
+ },
+ [enableLegendSlider, checkScroll]
+ );
+
+ React.useEffect(() => {
+ const keyDownHandler = (key: string) => {
+ if (key === 'ArrowLeft') {
+ scrollToTest('left');
+ } else if (key === 'ArrowRight') {
+ scrollToTest('right');
+ }
+ };
+ if (isKeyDowned) {
+ keyDownHandler(isKeyDowned);
+ intervalRef.current = setInterval(() => {
+ keyDownHandler(isKeyDowned);
+ }, 300);
+ } else {
+ clearInterval(intervalRef.current as NodeJS.Timeout);
+ }
+ return () => clearInterval(intervalRef.current as NodeJS.Timeout);
+ }, [isKeyDowned, scrollToTest]);
+
+ const keyDown = (e: KeyboardEvent) => {
+ e.stopPropagation();
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
+ e.preventDefault();
+ setIsKeyDowned(e.key);
+ }
+ };
+ const keyUp = (e: KeyboardEvent) => {
+ e.stopPropagation();
+ setIsKeyDowned(null);
+ };
+
+ React.useEffect(() => {
+ const scrollable = scrollableRef?.current;
+ if (enableLegendSlider) {
+ checkScroll();
+ scrollable?.addEventListener('keydown', keyDown);
+ scrollable?.addEventListener('keyup', keyUp);
+ }
+
+ return () => {
+ scrollable?.removeEventListener('keydown', keyDown);
+ scrollable?.removeEventListener('keyup', keyUp);
+ };
+ }, [checkScroll, enableLegendSlider]);
+
+ return (
+
+
+ {categories.map((category, index) => (
+
+ ))}
+
+ {enableLegendSlider && (hasScroll?.right || hasScroll?.left) ? (
+ <>
+
+ {
+ setIsKeyDowned(null);
+ scrollToTest('left');
+ }}
+ disabled={!hasScroll?.left}
+ />
+ {
+ setIsKeyDowned(null);
+ scrollToTest('right');
+ }}
+ disabled={!hasScroll?.right}
+ />
+
+ >
+ ) : null}
+
+ );
+});
+
+Legend.displayName = 'Legend';
+
+const ChartLegend = (
+ { payload }: any,
+ categoryColors: Map,
+ setLegendHeight: React.Dispatch>,
+ activeLegend: string | undefined,
+ onClick?: (category: string, color: string) => void,
+ enableLegendSlider?: boolean,
+ legendPosition?: 'left' | 'center' | 'right',
+ yAxisWidth?: number
+) => {
+ const legendRef = React.useRef(null);
+
+ useOnWindowResize(() => {
+ const calculateHeight = (height: number | undefined) => (height ? Number(height) + 15 : 60);
+ setLegendHeight(calculateHeight(legendRef.current?.clientHeight));
+ });
+
+ const legendPayload = payload.filter((item: any) => item.type !== 'none');
+
+ const paddingLeft = legendPosition === 'left' && yAxisWidth ? yAxisWidth - 8 : 0;
+
+ return (
+
+ entry.value)}
+ colors={legendPayload.map((entry: any) => categoryColors.get(entry.value))}
+ onClickLegendItem={onClick}
+ activeLegend={activeLegend}
+ enableLegendSlider={enableLegendSlider}
+ />
+
+ );
+};
+
+//#region Tooltip
+
+type TooltipProps = Pick;
+
+type PayloadItem = {
+ category: string;
+ value: number;
+ index: string;
+ color: AvailableChartColorsKeys;
+ type?: string;
+ payload: any;
+};
+
+interface ChartTooltipProps {
+ active: boolean | undefined;
+ payload: PayloadItem[];
+ label: string;
+ valueFormatter: (value: number) => string;
+}
+
+const ChartTooltip = ({ active, payload, label, valueFormatter }: ChartTooltipProps) => {
+ if (active && payload && payload.length) {
+ const legendPayload = payload.filter((item: any) => item.type !== 'none');
+ return (
+
+
+
+ {legendPayload.map(({ value, category, color }, index) => (
+
+
+
+ {valueFormatter(value)}
+
+
+ ))}
+
+
+ );
+ }
+ return null;
+};
+
+//#region LineChart
+
+interface ActiveDot {
+ index?: number;
+ dataKey?: string;
+}
+
+type BaseEventProps = {
+ eventType: 'dot' | 'category';
+ categoryClicked: string;
+ [key: string]: number | string;
+};
+
+type LineChartEventProps = BaseEventProps | null | undefined;
+
+interface LineChartProps extends React.HTMLAttributes {
+ data: Record[];
+ index: string;
+ categories: string[];
+ colors?: AvailableChartColorsKeys[];
+ valueFormatter?: (value: number) => string;
+ startEndOnly?: boolean;
+ showXAxis?: boolean;
+ showYAxis?: boolean;
+ showGridLines?: boolean;
+ yAxisWidth?: number;
+ intervalType?: 'preserveStartEnd' | 'equidistantPreserveStart';
+ showTooltip?: boolean;
+ showLegend?: boolean;
+ autoMinValue?: boolean;
+ minValue?: number;
+ maxValue?: number;
+ allowDecimals?: boolean;
+ onValueChange?: (value: LineChartEventProps) => void;
+ enableLegendSlider?: boolean;
+ tickGap?: number;
+ connectNulls?: boolean;
+ xAxisLabel?: string;
+ yAxisLabel?: string;
+ legendPosition?: 'left' | 'center' | 'right';
+ tooltipCallback?: (tooltipCallbackContent: TooltipProps) => void;
+ customTooltip?: React.ComponentType;
+}
+
+const LineChart = React.forwardRef((props, ref) => {
+ const {
+ data = [],
+ categories = [],
+ index,
+ colors = AvailableChartColors,
+ valueFormatter = (value: number) => value.toString(),
+ startEndOnly = false,
+ showXAxis = true,
+ showYAxis = true,
+ showGridLines = true,
+ yAxisWidth = 56,
+ intervalType = 'equidistantPreserveStart',
+ showTooltip = true,
+ showLegend = true,
+ autoMinValue = false,
+ minValue,
+ maxValue,
+ allowDecimals = true,
+ connectNulls = false,
+ className,
+ onValueChange,
+ enableLegendSlider = false,
+ tickGap = 5,
+ xAxisLabel,
+ yAxisLabel,
+ legendPosition = 'right',
+ tooltipCallback,
+ customTooltip,
+ ...other
+ } = props;
+ const CustomTooltip = customTooltip;
+ const paddingValue = (!showXAxis && !showYAxis) || (startEndOnly && !showYAxis) ? 0 : 20;
+ const [legendHeight, setLegendHeight] = React.useState(60);
+ const [activeDot, setActiveDot] = React.useState(undefined);
+ const [activeLegend, setActiveLegend] = React.useState(undefined);
+ const categoryColors = constructCategoryColors(categories, colors);
+
+ const yAxisDomain = getYAxisDomain(autoMinValue, minValue, maxValue);
+ const hasOnValueChange = !!onValueChange;
+ const prevActiveRef = React.useRef(undefined);
+ const prevLabelRef = React.useRef(undefined);
+
+ function onDotClick(itemData: any, event: React.MouseEvent) {
+ event.stopPropagation();
+
+ if (!hasOnValueChange) return;
+ if (
+ (itemData.index === activeDot?.index && itemData.dataKey === activeDot?.dataKey) ||
+ (hasOnlyOneValueForKey(data, itemData.dataKey) &&
+ activeLegend &&
+ activeLegend === itemData.dataKey)
+ ) {
+ setActiveLegend(undefined);
+ setActiveDot(undefined);
+ onValueChange?.(null);
+ } else {
+ setActiveLegend(itemData.dataKey);
+ setActiveDot({
+ index: itemData.index,
+ dataKey: itemData.dataKey,
+ });
+ onValueChange?.({
+ eventType: 'dot',
+ categoryClicked: itemData.dataKey,
+ ...itemData.payload,
+ });
+ }
+ }
+
+ function onCategoryClick(dataKey: string) {
+ if (!hasOnValueChange) return;
+ if (
+ (dataKey === activeLegend && !activeDot) ||
+ (hasOnlyOneValueForKey(data, dataKey) && activeDot && activeDot.dataKey === dataKey)
+ ) {
+ setActiveLegend(undefined);
+ onValueChange?.(null);
+ } else {
+ setActiveLegend(dataKey);
+ onValueChange?.({
+ eventType: 'category',
+ categoryClicked: dataKey,
+ });
+ }
+ setActiveDot(undefined);
+ }
+
+ return (
+
+
+ {
+ setActiveDot(undefined);
+ setActiveLegend(undefined);
+ onValueChange?.(null);
+ }
+ : undefined
+ }
+ margin={{
+ bottom: xAxisLabel ? 30 : undefined,
+ left: yAxisLabel ? 20 : undefined,
+ right: yAxisLabel ? 5 : undefined,
+ top: 5,
+ }}
+ >
+ {showGridLines ? (
+
+ ) : null}
+
+ {xAxisLabel && (
+
+ {xAxisLabel}
+
+ )}
+
+
+ {yAxisLabel && (
+
+ {yAxisLabel}
+
+ )}
+
+ {
+ const cleanPayload: TooltipProps['payload'] = payload
+ ? payload.map((item: any) => ({
+ category: item.dataKey,
+ value: item.value,
+ index: item.payload[index],
+ color: categoryColors.get(item.dataKey) as AvailableChartColorsKeys,
+ type: item.type,
+ payload: item.payload,
+ }))
+ : [];
+
+ if (
+ tooltipCallback &&
+ (active !== prevActiveRef.current || label !== prevLabelRef.current)
+ ) {
+ tooltipCallback({ active, payload: cleanPayload, label });
+ prevActiveRef.current = active;
+ prevLabelRef.current = label;
+ }
+
+ return showTooltip && active ? (
+ CustomTooltip ? (
+
+ ) : (
+
+ )
+ ) : null;
+ }}
+ />
+
+ {showLegend ? (
+
+ ChartLegend(
+ { payload },
+ categoryColors,
+ setLegendHeight,
+ activeLegend,
+ hasOnValueChange
+ ? (clickedLegendItem: string) => onCategoryClick(clickedLegendItem)
+ : undefined,
+ enableLegendSlider,
+ legendPosition,
+ yAxisWidth
+ )
+ }
+ />
+ ) : null}
+ {categories.map((category) => (
+ {
+ const {
+ cx: cxCoord,
+ cy: cyCoord,
+ stroke,
+ strokeLinecap,
+ strokeLinejoin,
+ strokeWidth,
+ dataKey,
+ } = props;
+ return (
+ onDotClick(props, event)}
+ />
+ );
+ }}
+ dot={(props: any) => {
+ const {
+ stroke,
+ strokeLinecap,
+ strokeLinejoin,
+ strokeWidth,
+ cx: cxCoord,
+ cy: cyCoord,
+ dataKey,
+ index,
+ } = props;
+
+ if (
+ (hasOnlyOneValueForKey(data, category) &&
+ !(activeDot || (activeLegend && activeLegend !== category))) ||
+ (activeDot?.index === index && activeDot?.dataKey === category)
+ ) {
+ return (
+
+ );
+ }
+ return ;
+ }}
+ key={category}
+ name={category}
+ type="linear"
+ dataKey={category}
+ stroke={getStrokeColor(categoryColors.get(category) as AvailableChartColorsKeys)}
+ strokeWidth={2}
+ strokeLinejoin="round"
+ strokeLinecap="round"
+ isAnimationActive={false}
+ connectNulls={connectNulls}
+ />
+ ))}
+ {/* hidden lines to increase clickable target area */}
+ {onValueChange
+ ? categories.map((category) => (
+ {
+ event.stopPropagation();
+ const { name } = props;
+ onCategoryClick(name);
+ }}
+ />
+ ))
+ : null}
+
+
+
+ );
+});
+
+LineChart.displayName = 'LineChart';
+
+export { LineChart, type LineChartEventProps, type TooltipProps };
diff --git a/src/components/charts/spark-bar-chart.stories.tsx b/src/components/charts/spark-bar-chart.stories.tsx
new file mode 100644
index 000000000..83fc81e2e
--- /dev/null
+++ b/src/components/charts/spark-bar-chart.stories.tsx
@@ -0,0 +1,124 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { SparkBarChart } from './spark-chart';
+
+// Generate mock data for the past 7 days
+const generateMockData = () => {
+ const today = new Date();
+ const data = [];
+
+ for (let i = 6; i >= 0; i--) {
+ const date = new Date(today);
+ date.setDate(today.getDate() - i);
+
+ // Format date as YYYY-MM-DD
+ const formattedDate = date.toISOString().split('T')[0];
+
+ // Generate random values between 10 and 100
+ const value1 = Math.floor(Math.random() * 91) + 10;
+ const value2 = Math.floor(Math.random() * 91) + 10;
+
+ data.push({
+ date: formattedDate,
+ value: value1,
+ secondValue: value2,
+ });
+ }
+
+ return data;
+};
+
+// Mock data with a clear upward trend
+const trendingUpData = [
+ { date: '2023-06-01', value: 15, secondValue: 10 },
+ { date: '2023-06-02', value: 25, secondValue: 18 },
+ { date: '2023-06-03', value: 32, secondValue: 22 },
+ { date: '2023-06-04', value: 40, secondValue: 28 },
+ { date: '2023-06-05', value: 52, secondValue: 35 },
+ { date: '2023-06-06', value: 58, secondValue: 42 },
+ { date: '2023-06-07', value: 65, secondValue: 50 },
+];
+
+// Mock data with a clear downward trend
+const trendingDownData = [
+ { date: '2023-06-01', value: 65, secondValue: 50 },
+ { date: '2023-06-02', value: 58, secondValue: 42 },
+ { date: '2023-06-03', value: 52, secondValue: 35 },
+ { date: '2023-06-04', value: 40, secondValue: 28 },
+ { date: '2023-06-05', value: 32, secondValue: 22 },
+ { date: '2023-06-06', value: 25, secondValue: 18 },
+ { date: '2023-06-07', value: 15, secondValue: 10 },
+];
+
+// Generate dynamic data for each story render
+const dynamicData = generateMockData();
+
+// Common decorator for all stories
+const chartDecorator = (Story: React.ComponentType) => (
+
+);
+
+const meta: Meta = {
+ title: 'Charts/SparkBarChart',
+ component: SparkBarChart,
+ parameters: {
+ layout: 'centered',
+ },
+ tags: ['autodocs'],
+ decorators: [chartDecorator],
+};
+
+export default meta;
+
+type SparkBarChartStory = StoryObj;
+
+export const Default: SparkBarChartStory = {
+ args: {
+ data: dynamicData,
+ index: 'date',
+ categories: ['value'],
+ colors: ['cyan'],
+ className: 'h-12 w-full',
+ },
+};
+
+export const AccentColor: SparkBarChartStory = {
+ args: {
+ ...Default.args,
+ colors: ['accent'],
+ },
+};
+
+export const TrendingUp: SparkBarChartStory = {
+ args: {
+ ...Default.args,
+ data: trendingUpData,
+ },
+};
+
+export const TrendingDown: SparkBarChartStory = {
+ args: {
+ ...Default.args,
+ data: trendingDownData,
+ colors: ['amber'],
+ },
+};
+
+export const MultipleSeries: SparkBarChartStory = {
+ args: {
+ ...Default.args,
+ categories: ['value', 'secondValue'],
+ colors: ['blue', 'emerald'],
+ },
+};
+
+export const Stacked: SparkBarChartStory = {
+ args: {
+ ...MultipleSeries.args,
+ type: 'stacked',
+ },
+};
diff --git a/src/components/charts/spark-chart.stories.tsx b/src/components/charts/spark-chart.stories.tsx
new file mode 100644
index 000000000..30a2f0e27
--- /dev/null
+++ b/src/components/charts/spark-chart.stories.tsx
@@ -0,0 +1,260 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { SparkAreaChart, SparkLineChart, SparkBarChart } from './spark-chart';
+
+// Generate mock data for the past 7 days
+const generateMockData = () => {
+ const today = new Date();
+ const data = [];
+
+ for (let i = 6; i >= 0; i--) {
+ const date = new Date(today);
+ date.setDate(today.getDate() - i);
+
+ // Format date as YYYY-MM-DD
+ const formattedDate = date.toISOString().split('T')[0];
+
+ // Generate random values between 10 and 100
+ const value1 = Math.floor(Math.random() * 91) + 10;
+ const value2 = Math.floor(Math.random() * 91) + 10;
+
+ data.push({
+ date: formattedDate,
+ value: value1,
+ secondValue: value2,
+ });
+ }
+
+ return data;
+};
+
+// Mock data with a clear upward trend
+const trendingUpData = [
+ { date: '2023-06-01', value: 15, secondValue: 10 },
+ { date: '2023-06-02', value: 25, secondValue: 18 },
+ { date: '2023-06-03', value: 32, secondValue: 22 },
+ { date: '2023-06-04', value: 40, secondValue: 28 },
+ { date: '2023-06-05', value: 52, secondValue: 35 },
+ { date: '2023-06-06', value: 58, secondValue: 42 },
+ { date: '2023-06-07', value: 65, secondValue: 50 },
+];
+
+// Mock data with a clear downward trend
+const trendingDownData = [
+ { date: '2023-06-01', value: 65, secondValue: 50 },
+ { date: '2023-06-02', value: 58, secondValue: 42 },
+ { date: '2023-06-03', value: 52, secondValue: 35 },
+ { date: '2023-06-04', value: 40, secondValue: 28 },
+ { date: '2023-06-05', value: 32, secondValue: 22 },
+ { date: '2023-06-06', value: 25, secondValue: 18 },
+ { date: '2023-06-07', value: 15, secondValue: 10 },
+];
+
+// Mock data with fluctuations
+const fluctuatingData = [
+ { date: '2023-06-01', value: 45, secondValue: 30 },
+ { date: '2023-06-02', value: 30, secondValue: 45 },
+ { date: '2023-06-03', value: 60, secondValue: 25 },
+ { date: '2023-06-04', value: 25, secondValue: 55 },
+ { date: '2023-06-05', value: 70, secondValue: 35 },
+ { date: '2023-06-06', value: 40, secondValue: 60 },
+ { date: '2023-06-07', value: 55, secondValue: 40 },
+];
+
+// Generate dynamic data for each story render
+const dynamicData = generateMockData();
+
+// Common decorator for all stories
+const chartDecorator = (Story: React.ComponentType) => (
+
+);
+
+// Default export for the main SparkAreaChart
+const meta: Meta = {
+ title: 'Charts/SparkAreaChart',
+ component: SparkAreaChart,
+ parameters: {
+ layout: 'centered',
+ },
+ tags: ['autodocs'],
+ decorators: [chartDecorator],
+};
+
+export default meta;
+
+// Type for SparkAreaChart stories
+type SparkAreaChartStory = StoryObj;
+
+// SparkAreaChart Stories
+export const Default: SparkAreaChartStory = {
+ args: {
+ data: dynamicData,
+ index: 'date',
+ categories: ['value'],
+ colors: ['cyan'],
+ fill: 'gradient',
+ className: 'h-40 w-96',
+ },
+};
+
+export const AccentColor: SparkAreaChartStory = {
+ args: {
+ ...Default.args,
+ colors: ['accent'],
+ },
+};
+
+export const TrendingUp: SparkAreaChartStory = {
+ args: {
+ ...Default.args,
+ data: trendingUpData,
+ },
+};
+
+export const TrendingDown: SparkAreaChartStory = {
+ args: {
+ ...Default.args,
+ data: trendingDownData,
+ colors: ['amber'],
+ },
+};
+
+export const MultipleSeries: SparkAreaChartStory = {
+ args: {
+ ...Default.args,
+ categories: ['value', 'secondValue'],
+ colors: ['cyan', 'emerald'],
+ },
+};
+
+export const SolidFill: SparkAreaChartStory = {
+ args: {
+ ...Default.args,
+ fill: 'solid',
+ },
+};
+
+export const NoFill: SparkAreaChartStory = {
+ args: {
+ ...Default.args,
+ fill: 'none',
+ },
+};
+
+export const Stacked: SparkAreaChartStory = {
+ args: {
+ ...MultipleSeries.args,
+ type: 'stacked',
+ },
+};
+
+/**
+ * SparkLineChart Stories
+ */
+const lineChartMeta: Meta = {
+ title: 'Charts/SparkLineChart',
+ component: SparkLineChart,
+ parameters: {
+ layout: 'centered',
+ },
+ tags: ['autodocs'],
+ decorators: [chartDecorator],
+};
+
+export const LineChartDefault: StoryObj = {
+ args: {
+ data: dynamicData,
+ index: 'date',
+ categories: ['value'],
+ colors: ['cyan'],
+ className: 'h-40 w-96',
+ },
+};
+
+export const LineChartTrendingUp: StoryObj = {
+ args: {
+ ...LineChartDefault.args,
+ data: trendingUpData,
+ colors: ['emerald'],
+ },
+};
+
+export const LineChartTrendingDown: StoryObj = {
+ args: {
+ ...LineChartDefault.args,
+ data: trendingDownData,
+ colors: ['pink'],
+ },
+};
+
+export const LineChartFluctuating: StoryObj = {
+ args: {
+ ...LineChartDefault.args,
+ data: fluctuatingData,
+ },
+};
+
+export const LineChartMultipleSeries: StoryObj = {
+ args: {
+ ...LineChartDefault.args,
+ categories: ['value', 'secondValue'],
+ colors: ['cyan', 'amber'],
+ },
+};
+
+/**
+ * SparkBarChart Stories
+ */
+const barChartMeta: Meta = {
+ title: 'Charts/SparkBarChart',
+ component: SparkBarChart,
+ parameters: {
+ layout: 'centered',
+ },
+ tags: ['autodocs'],
+ decorators: [chartDecorator],
+};
+
+export const BarChartDefault: StoryObj = {
+ args: {
+ data: dynamicData,
+ index: 'date',
+ categories: ['value'],
+ colors: ['cyan'],
+ className: 'h-12 w-full',
+ },
+};
+
+export const BarChartTrendingUp: StoryObj = {
+ args: {
+ ...BarChartDefault.args,
+ data: trendingUpData,
+ },
+};
+
+export const BarChartTrendingDown: StoryObj = {
+ args: {
+ ...BarChartDefault.args,
+ data: trendingDownData,
+ colors: ['amber'],
+ },
+};
+
+export const BarChartMultipleSeries: StoryObj = {
+ args: {
+ ...BarChartDefault.args,
+ categories: ['value', 'secondValue'],
+ colors: ['blue', 'emerald'],
+ },
+};
+
+export const BarChartStacked: StoryObj = {
+ args: {
+ ...BarChartMultipleSeries.args,
+ type: 'stacked',
+ },
+};
diff --git a/src/components/charts/spark-chart.tsx b/src/components/charts/spark-chart.tsx
new file mode 100644
index 000000000..7759c79c1
--- /dev/null
+++ b/src/components/charts/spark-chart.tsx
@@ -0,0 +1,329 @@
+// Tremor Spark Chart [v0.1.2]
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+'use client';
+
+import React from 'react';
+import {
+ Area,
+ Bar,
+ Line,
+ AreaChart as RechartsAreaChart,
+ BarChart as RechartsBarChart,
+ LineChart as RechartsLineChart,
+ ResponsiveContainer,
+ XAxis,
+ YAxis,
+} from 'recharts';
+import { AxisDomain } from 'recharts/types/util/types';
+
+import {
+ AvailableChartColors,
+ AvailableChartColorsKeys,
+ constructCategoryColors,
+ getColorClassName,
+ getYAxisDomain,
+} from '@/lib/chart-utils';
+import { cn as cx } from '@/lib/utils';
+
+// Helper function to get direct color value for SVG elements
+const getDirectColor = (color: AvailableChartColorsKeys): string => {
+ if (color === 'accent') {
+ return '#5b61d6'; // Match the accent color in globals.css
+ }
+ return ''; // Return empty string to use className-based coloring for other colors
+};
+
+//#region SparkAreaChart
+
+interface SparkAreaChartProps extends React.HTMLAttributes {
+ data: Record[];
+ categories: string[];
+ index: string;
+ colors?: AvailableChartColorsKeys[];
+ autoMinValue?: boolean;
+ minValue?: number;
+ maxValue?: number;
+ connectNulls?: boolean;
+ type?: 'default' | 'stacked' | 'percent';
+ fill?: 'gradient' | 'solid' | 'none';
+}
+
+const SparkAreaChart = React.forwardRef(
+ (props, forwardedRef) => {
+ const {
+ data = [],
+ categories = [],
+ index,
+ colors = AvailableChartColors,
+ autoMinValue = false,
+ minValue,
+ maxValue,
+ connectNulls = false,
+ type = 'default',
+ className,
+ fill = 'gradient',
+ ...other
+ } = props;
+
+ const categoryColors = constructCategoryColors(categories, colors);
+ const yAxisDomain = getYAxisDomain(autoMinValue, minValue, maxValue);
+ const stacked = type === 'stacked' || type === 'percent';
+ const areaId = React.useId();
+
+ const getFillContent = (fillType: SparkAreaChartProps['fill']) => {
+ switch (fillType) {
+ case 'none':
+ return ;
+ case 'gradient':
+ return (
+ <>
+
+
+ >
+ );
+ case 'solid':
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ {categories.map((category) => {
+ const categoryId = `${areaId}-${category.replace(/[^a-zA-Z0-9]/g, '')}`;
+ return (
+
+
+
+ {getFillContent(fill)}
+
+
+
+
+ );
+ })}
+
+
+
+ );
+ }
+);
+
+SparkAreaChart.displayName = 'SparkAreaChart';
+
+//#region SparkLineChart
+
+interface SparkLineChartProps extends React.HTMLAttributes {
+ data: Record[];
+ categories: string[];
+ index: string;
+ colors?: AvailableChartColorsKeys[];
+ autoMinValue?: boolean;
+ minValue?: number;
+ maxValue?: number;
+ connectNulls?: boolean;
+}
+
+const SparkLineChart = React.forwardRef(
+ (props, forwardedRef) => {
+ const {
+ data = [],
+ categories = [],
+ index,
+ colors = AvailableChartColors,
+ autoMinValue = false,
+ minValue,
+ maxValue,
+ connectNulls = false,
+ className,
+ ...other
+ } = props;
+
+ const categoryColors = constructCategoryColors(categories, colors);
+ const yAxisDomain = getYAxisDomain(autoMinValue, minValue, maxValue);
+
+ return (
+
+
+
+
+
+ {categories.map((category) => (
+
+ ))}
+
+
+
+ );
+ }
+);
+
+SparkLineChart.displayName = 'SparkLineChart';
+
+//#region SparkBarChart
+
+interface BarChartProps extends React.HTMLAttributes {
+ data: Record[];
+ index: string;
+ categories: string[];
+ colors?: AvailableChartColorsKeys[];
+ autoMinValue?: boolean;
+ minValue?: number;
+ maxValue?: number;
+ barCategoryGap?: string | number;
+ type?: 'default' | 'stacked' | 'percent';
+}
+
+const SparkBarChart = React.forwardRef((props, forwardedRef) => {
+ const {
+ data = [],
+ categories = [],
+ index,
+ colors = AvailableChartColors,
+ autoMinValue = false,
+ minValue,
+ maxValue,
+ barCategoryGap,
+ type = 'default',
+ className,
+ ...other
+ } = props;
+
+ const categoryColors = constructCategoryColors(categories, colors);
+
+ const yAxisDomain = getYAxisDomain(autoMinValue, minValue, maxValue);
+ const stacked = type === 'stacked' || type === 'percent';
+
+ return (
+
+
+
+
+
+
+ {categories.map((category) => (
+
+ ))}
+
+
+
+ );
+});
+
+SparkBarChart.displayName = 'SparkBarChart';
+
+export { SparkAreaChart, SparkLineChart, SparkBarChart };
diff --git a/src/components/charts/spark-line-chart.stories.tsx b/src/components/charts/spark-line-chart.stories.tsx
new file mode 100644
index 000000000..bf5569e55
--- /dev/null
+++ b/src/components/charts/spark-line-chart.stories.tsx
@@ -0,0 +1,136 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { SparkLineChart } from './spark-chart';
+
+// Generate mock data for the past 7 days
+const generateMockData = () => {
+ const today = new Date();
+ const data = [];
+
+ for (let i = 6; i >= 0; i--) {
+ const date = new Date(today);
+ date.setDate(today.getDate() - i);
+
+ // Format date as YYYY-MM-DD
+ const formattedDate = date.toISOString().split('T')[0];
+
+ // Generate random values between 10 and 100
+ const value1 = Math.floor(Math.random() * 91) + 10;
+ const value2 = Math.floor(Math.random() * 91) + 10;
+
+ data.push({
+ date: formattedDate,
+ value: value1,
+ secondValue: value2,
+ });
+ }
+
+ return data;
+};
+
+// Mock data with a clear upward trend
+const trendingUpData = [
+ { date: '2023-06-01', value: 15, secondValue: 10 },
+ { date: '2023-06-02', value: 25, secondValue: 18 },
+ { date: '2023-06-03', value: 32, secondValue: 22 },
+ { date: '2023-06-04', value: 40, secondValue: 28 },
+ { date: '2023-06-05', value: 52, secondValue: 35 },
+ { date: '2023-06-06', value: 58, secondValue: 42 },
+ { date: '2023-06-07', value: 65, secondValue: 50 },
+];
+
+// Mock data with a clear downward trend
+const trendingDownData = [
+ { date: '2023-06-01', value: 65, secondValue: 50 },
+ { date: '2023-06-02', value: 58, secondValue: 42 },
+ { date: '2023-06-03', value: 52, secondValue: 35 },
+ { date: '2023-06-04', value: 40, secondValue: 28 },
+ { date: '2023-06-05', value: 32, secondValue: 22 },
+ { date: '2023-06-06', value: 25, secondValue: 18 },
+ { date: '2023-06-07', value: 15, secondValue: 10 },
+];
+
+// Mock data with fluctuations
+const fluctuatingData = [
+ { date: '2023-06-01', value: 45, secondValue: 30 },
+ { date: '2023-06-02', value: 30, secondValue: 45 },
+ { date: '2023-06-03', value: 60, secondValue: 25 },
+ { date: '2023-06-04', value: 25, secondValue: 55 },
+ { date: '2023-06-05', value: 70, secondValue: 35 },
+ { date: '2023-06-06', value: 40, secondValue: 60 },
+ { date: '2023-06-07', value: 55, secondValue: 40 },
+];
+
+// Generate dynamic data for each story render
+const dynamicData = generateMockData();
+
+// Common decorator for all stories
+const chartDecorator = (Story: React.ComponentType) => (
+
+);
+
+const meta: Meta = {
+ title: 'Charts/SparkLineChart',
+ component: SparkLineChart,
+ parameters: {
+ layout: 'centered',
+ },
+ tags: ['autodocs'],
+ decorators: [chartDecorator],
+};
+
+export default meta;
+
+type SparkLineChartStory = StoryObj;
+
+export const Default: SparkLineChartStory = {
+ args: {
+ data: dynamicData,
+ index: 'date',
+ categories: ['value'],
+ colors: ['cyan'],
+ className: 'h-12 w-full',
+ },
+};
+
+export const AccentColor: SparkLineChartStory = {
+ args: {
+ ...Default.args,
+ colors: ['accent'],
+ },
+};
+
+export const TrendingUp: SparkLineChartStory = {
+ args: {
+ ...Default.args,
+ data: trendingUpData,
+ colors: ['emerald'],
+ },
+};
+
+export const TrendingDown: SparkLineChartStory = {
+ args: {
+ ...Default.args,
+ data: trendingDownData,
+ colors: ['pink'],
+ },
+};
+
+export const Fluctuating: SparkLineChartStory = {
+ args: {
+ ...Default.args,
+ data: fluctuatingData,
+ },
+};
+
+export const MultipleSeries: SparkLineChartStory = {
+ args: {
+ ...Default.args,
+ categories: ['value', 'secondValue'],
+ colors: ['cyan', 'amber'],
+ },
+};
diff --git a/src/components/charts/tags-chart.stories.tsx b/src/components/charts/tags-chart.stories.tsx
new file mode 100644
index 000000000..fa4a43b8f
--- /dev/null
+++ b/src/components/charts/tags-chart.stories.tsx
@@ -0,0 +1,137 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import TagsChart from './tags-chart';
+
+// Define types for TagsChart data items
+type TagItem = {
+ name: string;
+ value: number;
+};
+
+// Mock data for programming languages tags
+const programmingTags: TagItem[] = [
+ { name: 'JavaScript', value: 875 },
+ { name: 'Python', value: 743 },
+ { name: 'CSS', value: 521 },
+ { name: 'HTML', value: 489 },
+ { name: 'TypeScript', value: 382 },
+ { name: 'React', value: 367 },
+ { name: 'Node.js', value: 298 },
+ { name: 'SQL', value: 276 },
+ { name: 'Git', value: 245 },
+ { name: 'API', value: 187 },
+];
+
+// Mock data for blog category tags
+const blogCategoryTags: TagItem[] = [
+ { name: 'Tutorials', value: 324 },
+ { name: 'Best Practices', value: 218 },
+ { name: 'Frameworks', value: 189 },
+ { name: 'Security', value: 156 },
+ { name: 'Performance', value: 142 },
+ { name: 'Web Development', value: 137 },
+ { name: 'Mobile', value: 95 },
+ { name: 'DevOps', value: 83 },
+ { name: 'Career', value: 76 },
+ { name: 'AI', value: 68 },
+];
+
+// Mock data for trending topics
+const trendingTags: TagItem[] = [
+ { name: 'Next.js', value: 523 },
+ { name: 'TailwindCSS', value: 487 },
+ { name: 'AI Tools', value: 412 },
+ { name: 'WebAssembly', value: 245 },
+ { name: 'Serverless', value: 234 },
+ { name: 'GraphQL', value: 187 },
+ { name: 'Microservices', value: 165 },
+ { name: 'Blockchain', value: 124 },
+ { name: 'Kubernetes', value: 98 },
+ { name: 'AR/VR', value: 65 },
+];
+
+// Mock data for framework popularity
+const frameworkTags: TagItem[] = [
+ { name: 'React', value: 367 },
+ { name: 'Vue', value: 289 },
+ { name: 'Angular', value: 234 },
+ { name: 'Svelte', value: 189 },
+ { name: 'Next.js', value: 176 },
+ { name: 'Nuxt', value: 143 },
+ { name: 'Astro', value: 112 },
+ { name: 'SolidJS', value: 87 },
+ { name: 'Ember', value: 54 },
+ { name: 'Express', value: 43 },
+];
+
+const meta = {
+ component: TagsChart,
+ parameters: {
+ layout: 'centered',
+ },
+ tags: ['autodocs'],
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ data: programmingTags,
+ backgroundColor: 'bg-black',
+ barColor: 'bg-accent',
+ title: 'Top 10 Tags',
+ },
+};
+
+export const BlogCategories: Story = {
+ args: {
+ data: blogCategoryTags,
+ backgroundColor: 'bg-zinc-900',
+ barColor: 'bg-accent',
+ title: 'Blog Categories',
+ },
+};
+
+export const TrendingTopics: Story = {
+ args: {
+ data: trendingTags,
+ backgroundColor: 'bg-black',
+ barColor: 'bg-accent',
+ title: 'Trending Topics This Month',
+ },
+};
+
+export const Frameworks: Story = {
+ args: {
+ data: frameworkTags,
+ backgroundColor: 'bg-zinc-900',
+ barColor: 'bg-accent',
+ title: 'Framework Popularity',
+ },
+};
+
+export const NoTitle: Story = {
+ args: {
+ data: programmingTags.slice(0, 5),
+ backgroundColor: 'bg-black',
+ barColor: 'bg-accent',
+ },
+};
+
+export const CustomBackground: Story = {
+ args: {
+ data: trendingTags.slice(0, 6),
+ backgroundColor: 'bg-indigo-950',
+ barColor: 'bg-accent',
+ title: 'Custom Background Example',
+ },
+};
diff --git a/src/components/charts/tags-chart.tsx b/src/components/charts/tags-chart.tsx
new file mode 100644
index 000000000..5df534863
--- /dev/null
+++ b/src/components/charts/tags-chart.tsx
@@ -0,0 +1,36 @@
+import { cn } from '@/lib/utils';
+import { BarList } from './bar-list-chart';
+import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
+import SPulse2 from '../ui/icons/s-pulse-2';
+import { Separator } from '../ui/separator';
+
+interface TagsChartProps {
+ data: {
+ name: string;
+ value: number;
+ }[];
+ backgroundColor?: string;
+ title?: string;
+ barColor?: string;
+}
+
+export default function TagsChart({ data, backgroundColor, title, barColor }: TagsChartProps) {
+ return (
+
+
+
+ {title}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/charts/tooltip.tsx b/src/components/charts/tooltip.tsx
new file mode 100644
index 000000000..11bcf1d1e
--- /dev/null
+++ b/src/components/charts/tooltip.tsx
@@ -0,0 +1,98 @@
+import { format } from 'date-fns';
+import { Separator } from '../ui/separator';
+import { capitalise } from '@/utils';
+
+// Define PayloadItem to cover various formats from different chart types
+interface PayloadItem {
+ // From native recharts
+ name?: string;
+ value?: number;
+ dataKey?: string;
+ payload?: any;
+ color?: string;
+ stroke?: string;
+ fill?: string;
+
+ // From customized charts
+ category?: string;
+ index?: string;
+}
+
+interface CustomTooltipProps {
+ active?: boolean;
+ payload?: PayloadItem[];
+ label?: string;
+}
+
+// Function to get correct color for tooltip
+const getTooltipColor = (entry: PayloadItem): string => {
+ // First check the specified color
+ const colorName = entry.color || '';
+
+ // If it's an accent color, use the CSS variable
+ if (colorName === 'accent') {
+ return '#5b61d6'; // Hardcoded accent color
+ }
+
+ // Otherwise use the provided color or fallbacks
+ return entry.color || entry.stroke || entry.fill || '#3b82f6';
+};
+
+export default function Tooltip({ active, payload, label }: CustomTooltipProps) {
+ if (!active || !payload || payload.length === 0) {
+ return null;
+ }
+
+ // Format date if possible
+ let formattedLabel = label || '';
+ try {
+ const date = new Date(formattedLabel);
+ if (!isNaN(date.getTime())) {
+ formattedLabel = format(date, 'MMM d, yyyy');
+ }
+ } catch (e) {
+ // Use original label if date parsing fails
+ }
+
+ return (
+
+
{formattedLabel}
+
+ {payload.map((entry, index) => {
+ // Get the display name (try different properties)
+ const name = entry.category || entry.name || entry.dataKey || 'Value';
+
+ // Get the value to display (try different approaches)
+ let displayValue: number | string = 0;
+
+ // Direct value from entry
+ if (typeof entry.value === 'number') {
+ displayValue = entry.value;
+ }
+ // Try to get from payload if available
+ else if (entry.payload) {
+ const key = entry.dataKey || entry.name || 'questions';
+ if (typeof entry.payload[key] === 'number') {
+ displayValue = entry.payload[key];
+ }
+ }
+
+ // Get the appropriate color
+ const color = getTooltipColor(entry);
+
+ return (
+
+
+
+
{capitalise(name)}:
+
+
{displayValue}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/charts/total-question-chart.stories.tsx b/src/components/charts/total-question-chart.stories.tsx
new file mode 100644
index 000000000..f7297df39
--- /dev/null
+++ b/src/components/charts/total-question-chart.stories.tsx
@@ -0,0 +1,162 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import QuestionChart from './total-question-chart';
+import { StatsChartData } from './total-question-chart';
+
+const meta: Meta = {
+ title: 'Charts/QuestionChart',
+ component: QuestionChart,
+ parameters: {
+ layout: 'centered',
+ backgrounds: {
+ default: 'dark',
+ },
+ },
+ tags: ['autodocs'],
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+type Story = StoryObj;
+
+// Generate dates for the past week
+const generateDates = (days: number) => {
+ const dates: string[] = [];
+ const today = new Date();
+
+ for (let i = days - 1; i >= 0; i--) {
+ const date = new Date(today);
+ date.setDate(today.getDate() - i);
+
+ // Format date as "Month Day, Year"
+ const formattedDate = date.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+
+ dates.push(formattedDate);
+ }
+
+ return dates;
+};
+
+// Create weekly data
+const createWeeklyData = (): StatsChartData => {
+ const weekDates = generateDates(7);
+ const data: StatsChartData = {};
+
+ weekDates.forEach((date, index) => {
+ // Generate increasing count pattern with some randomness
+ const baseCount = 20 + index * 5;
+ const randomVariation = Math.floor(Math.random() * 10) - 5; // -5 to +5
+ const totalQuestions = Math.max(0, baseCount + randomVariation);
+
+ // Generate some tag counts
+ const tagCounts: Record = {
+ javascript: Math.floor(totalQuestions * 0.4),
+ react: Math.floor(totalQuestions * 0.3),
+ typescript: Math.floor(totalQuestions * 0.2),
+ css: Math.floor(totalQuestions * 0.1),
+ };
+
+ // Add to data object
+ data[date] = {
+ totalQuestions,
+ tagCounts,
+ tags: Object.keys(tagCounts),
+ };
+ });
+
+ return data;
+};
+
+// Create monthly data
+const createMonthlyData = (): StatsChartData => {
+ const monthDates = generateDates(30);
+ const data: StatsChartData = {};
+
+ monthDates.forEach((date, index) => {
+ // Generate an upward trend with some fluctuations
+ const baseCount = 15 + Math.floor(index * 2.5);
+ const randomVariation = Math.floor(Math.random() * 15) - 7; // -7 to +7
+ const totalQuestions = Math.max(0, baseCount + randomVariation);
+
+ // Generate some tag counts
+ const tagCounts: Record = {
+ javascript: Math.floor(totalQuestions * 0.35),
+ react: Math.floor(totalQuestions * 0.25),
+ typescript: Math.floor(totalQuestions * 0.2),
+ css: Math.floor(totalQuestions * 0.1),
+ node: Math.floor(totalQuestions * 0.1),
+ };
+
+ // Add to data object
+ data[date] = {
+ totalQuestions,
+ tagCounts,
+ tags: Object.keys(tagCounts),
+ };
+ });
+
+ return data;
+};
+
+// Create mock data with a downward trend
+const createDownwardTrendData = (): StatsChartData => {
+ const weekDates = generateDates(7);
+ const data: StatsChartData = {};
+
+ weekDates.forEach((date, index) => {
+ // Generate decreasing count pattern
+ const baseCount = 70 - index * 8;
+ const randomVariation = Math.floor(Math.random() * 6) - 3; // -3 to +3
+ const totalQuestions = Math.max(0, baseCount + randomVariation);
+
+ // Generate some tag counts
+ const tagCounts: Record = {
+ javascript: Math.floor(totalQuestions * 0.4),
+ react: Math.floor(totalQuestions * 0.3),
+ typescript: Math.floor(totalQuestions * 0.2),
+ css: Math.floor(totalQuestions * 0.1),
+ };
+
+ // Add to data object
+ data[date] = {
+ totalQuestions,
+ tagCounts,
+ tags: Object.keys(tagCounts),
+ };
+ });
+
+ return data;
+};
+
+export const WeeklyData: Story = {
+ args: {
+ questionData: createWeeklyData(),
+ step: 'day',
+ backgroundColor: 'bg-black',
+ },
+};
+
+export const MonthlyData: Story = {
+ args: {
+ questionData: createMonthlyData(),
+ step: 'day',
+ backgroundColor: 'bg-black',
+ },
+};
+
+export const DownwardTrend: Story = {
+ args: {
+ questionData: createDownwardTrendData(),
+ step: 'day',
+ backgroundColor: 'bg-black',
+ },
+};
diff --git a/src/components/charts/total-question-chart.tsx b/src/components/charts/total-question-chart.tsx
new file mode 100644
index 000000000..cb8ef29dc
--- /dev/null
+++ b/src/components/charts/total-question-chart.tsx
@@ -0,0 +1,229 @@
+'use client';
+
+import React, { useMemo, useState } from 'react';
+import { TrendingUp, TrendingDown, Circle } from 'lucide-react';
+import NumberFlow from '@number-flow/react';
+
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { cn } from '@/lib/utils';
+import Tooltip from './tooltip';
+import { LineChart } from './line-chart';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
+import { Separator } from '../ui/separator';
+
+export interface StatsChartData {
+ [key: string]: {
+ totalQuestions: number;
+ tagCounts: Record;
+ tags: string[];
+ };
+}
+
+export default function QuestionChart({
+ questionData,
+ step: initialStep,
+ backgroundColor,
+}: {
+ questionData: StatsChartData | null;
+ step: 'day' | 'week' | 'month';
+ backgroundColor?: string;
+}) {
+ const [step, setStep] = useState<'day' | 'week' | 'month' | 'year'>(initialStep);
+
+ // Get the appropriate number of periods based on selected step
+ const getPeriodsToShow = () => {
+ switch (step) {
+ case 'day':
+ return 7;
+ case 'week':
+ return 30;
+ case 'month':
+ return 90;
+ case 'year':
+ return 365;
+ default:
+ return 7;
+ }
+ };
+
+ const chartData = useMemo(() => {
+ if (!questionData) return [];
+
+ const entries = Object.entries(questionData);
+
+ // Sort entries by date - latest first
+ entries.sort((a, b) => {
+ const [dateA] = a;
+ const [dateB] = b;
+ return new Date(dateB).getTime() - new Date(dateA).getTime();
+ });
+
+ // Directly use the keys as they should now be pre-formatted
+ return entries.map(([date, data]) => ({
+ date: date.split(',')[0],
+ questions: data.totalQuestions,
+ }));
+ }, [questionData]);
+
+ // order the chart data by the date. Ensuring that the oldest date is first
+ const orderedChartData = useMemo(() => {
+ // First, sort all data by date (oldest first)
+ const allSortedData = [...chartData].sort((a, b) => {
+ return new Date(a.date).getTime() - new Date(b.date).getTime();
+ });
+
+ // Get the number of periods we want to display based on the step
+ const periodsToShow = getPeriodsToShow();
+
+ // Return only the most recent periodsToShow items
+ return allSortedData.slice(-periodsToShow);
+ }, [chartData, step]);
+
+ // Add debugging for chart data
+ console.log('Chart data:', orderedChartData);
+ console.log('Categories:', ['questions']);
+ console.log(
+ 'Chart data keys:',
+ orderedChartData.length > 0 ? Object.keys(orderedChartData[0]) : []
+ );
+
+ const trend = useMemo(() => {
+ // if there is less than 2 periods, return 0
+ if (orderedChartData.length < 2) {
+ return { percentage: 0, isNeutral: true };
+ }
+
+ // get the first and last period of the chart data
+ const firstPeriod = orderedChartData[0];
+ const lastPeriod = orderedChartData[orderedChartData.length - 1];
+
+ // Handle case where first period has 0 questions
+ if (firstPeriod.questions === 0) {
+ if (lastPeriod.questions === 0) {
+ return { percentage: 0, isNeutral: true };
+ }
+ // If starting from 0, treat as 0 * lastPeriod.questions increase
+ return {
+ percentage: 100 * lastPeriod.questions,
+ isNeutral: false,
+ isUp: true,
+ };
+ }
+
+ // calculate the percentage change between the first and last period
+ const percentageChange =
+ ((lastPeriod.questions - firstPeriod.questions) / firstPeriod.questions) * 100;
+
+ return {
+ percentage: Math.abs(percentageChange).toFixed(2),
+ isNeutral: percentageChange === 0,
+ isUp: percentageChange > 0,
+ };
+ }, [orderedChartData]);
+
+ // Format value for the chart to show whole numbers with commas
+ const valueFormatter = (value: number) =>
+ new Intl.NumberFormat('en-US', {
+ maximumFractionDigits: 0,
+ }).format(value);
+
+ // Get display text for selected period
+ const getStepDisplayText = () => {
+ switch (step) {
+ case 'day':
+ return 'Last 7 days';
+ case 'week':
+ return 'Last 30 days';
+ case 'month':
+ return 'Last 3 months';
+ case 'year':
+ return 'Last 12 months';
+ default:
+ return 'Last 7 days';
+ }
+ };
+
+ const textSize = 'text-xl font-medium leading-none font-onest';
+
+ return (
+
+
+
+
+ {getStepDisplayText()}
+
+
+
+ Questions Answered
+
+
+
+ {/** step changer */}
+
+ setStep(value as 'day' | 'week' | 'month' | 'year')}
+ >
+
+
+
+
+ Last 7 days
+ Last 30 days
+ Last 3 months
+ Last 12 months
+
+
+
+
+
+
+ {/* Check if there's data to display */}
+ {orderedChartData.length > 0 ? (
+ }
+ tickGap={20}
+ connectNulls
+ autoMinValue
+ />
+ ) : (
+
+ )}
+
+
+
+
+ {trend.isUp ? '+' : '-'}
+
+ (
+ %)
+
+
+
vs last month
+
+
+
+
+
+ );
+}
diff --git a/src/components/charts/tracker.stories.tsx b/src/components/charts/tracker.stories.tsx
new file mode 100644
index 000000000..c2bf648ef
--- /dev/null
+++ b/src/components/charts/tracker.stories.tsx
@@ -0,0 +1,24 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { Tracker } from './tracker';
+
+const meta = {
+ component: Tracker,
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ data: [
+ {
+ color: '#000000',
+ tooltip: 'Tooltip',
+ hoverEffect: true,
+ },
+ ],
+ defaultBackgroundColor: '#000000',
+ },
+};
diff --git a/src/components/charts/tracker.tsx b/src/components/charts/tracker.tsx
new file mode 100644
index 000000000..949e3ca7c
--- /dev/null
+++ b/src/components/charts/tracker.tsx
@@ -0,0 +1,99 @@
+// Tremor Tracker [v0.1.3]
+
+import React from 'react';
+import * as HoverCardPrimitives from '@radix-ui/react-hover-card';
+
+import { cn } from '@/lib/utils';
+
+interface TrackerBlockProps {
+ key?: string | number;
+ color?: string;
+ tooltip?: string;
+ hoverEffect?: boolean;
+ defaultBackgroundColor?: string;
+}
+
+const Block = ({ color, tooltip, defaultBackgroundColor, hoverEffect }: TrackerBlockProps) => {
+ const [open, setOpen] = React.useState(false);
+ return (
+
+ setOpen(true)} asChild>
+
+
+
+
+ {tooltip}
+
+
+
+ );
+};
+
+Block.displayName = 'Block';
+
+interface TrackerProps extends React.HTMLAttributes {
+ data: TrackerBlockProps[];
+ defaultBackgroundColor?: string;
+ hoverEffect?: boolean;
+}
+
+const Tracker = React.forwardRef(
+ (
+ {
+ data = [],
+ defaultBackgroundColor = 'bg-gray-400 dark:bg-gray-400',
+ className,
+ hoverEffect,
+ ...props
+ },
+ forwardedRef
+ ) => {
+ return (
+
+ {data.map((props, index) => (
+
+ ))}
+
+ );
+ }
+);
+
+Tracker.displayName = 'Tracker';
+
+export { Tracker, type TrackerBlockProps };
diff --git a/src/components/marketing/features/statistics/stats-report-section.tsx b/src/components/marketing/features/statistics/stats-report-section.tsx
index be8473fca..dc4fb7c7a 100644
--- a/src/components/marketing/features/statistics/stats-report-section.tsx
+++ b/src/components/marketing/features/statistics/stats-report-section.tsx
@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect, useMemo, Suspense } from 'react';
-import QuestionChart from '@/components/app/statistics/total-question-chart';
+import QuestionChart from '@/components/charts/total-question-chart';
import SkewedQuestionCards from './skewed-question-cards';
import { capitalise, generateFakeData, getQuestionDifficultyColor } from '@/utils';
import { Badge } from '@/components/ui/badge';
diff --git a/src/components/marketing/homepage/features/features-bento-grid.tsx b/src/components/marketing/homepage/features/features-bento-grid.tsx
index 96e2f469f..fb8d79af8 100644
--- a/src/components/marketing/homepage/features/features-bento-grid.tsx
+++ b/src/components/marketing/homepage/features/features-bento-grid.tsx
@@ -59,7 +59,7 @@ export default async function FeaturesBentoGrid() {
variant="default"
className="z-10 relative gap-x-2 items-center w-fit font-onest hidden md:flex"
>
- Explore roadmaps
+ Explore roadmaps
diff --git a/src/components/marketing/homepage/features/progression-box.tsx b/src/components/marketing/homepage/features/progression-box.tsx
index ec16fabf9..8e1c432fd 100644
--- a/src/components/marketing/homepage/features/progression-box.tsx
+++ b/src/components/marketing/homepage/features/progression-box.tsx
@@ -1,15 +1,20 @@
-import dynamic from 'next/dynamic';
+import DifficultyRadialChart from '@/components/app/statistics/difficulty-radial-chart';
-const ProgressChart = dynamic(() => import('./progression-chart'), {
- ssr: false,
-});
+const mockData = {
+ all: {
+ totalQuestions: 175,
+ tagCounts: {},
+ tags: [],
+ difficulties: { BEGINNER: 101, EASY: 34, MEDIUM: 31, HARD: 9 },
+ },
+};
export default function ProgressionBentoBox() {
return (
<>
{/* Top Card */}
-
-
+
+
>
);
diff --git a/src/components/marketing/homepage/features/progression-chart.tsx b/src/components/marketing/homepage/features/progression-chart.tsx
index ff1c9e412..d40bd763a 100644
--- a/src/components/marketing/homepage/features/progression-chart.tsx
+++ b/src/components/marketing/homepage/features/progression-chart.tsx
@@ -18,7 +18,7 @@ import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
-} from '@/components/ui/chart';
+} from '@/components/charts/chart';
const months = [
'January',
diff --git a/src/components/shared/hero.tsx b/src/components/shared/hero.tsx
index 6ae121722..b072d91f8 100644
--- a/src/components/shared/hero.tsx
+++ b/src/components/shared/hero.tsx
@@ -7,8 +7,16 @@ export default function Hero(opts: {
children?: React.ReactNode;
container?: boolean;
chip?: React.ReactNode;
+ gridPosition?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
}) {
- const { heading, subheading, children, container = true, chip } = opts;
+ const {
+ heading,
+ subheading,
+ children,
+ container = true,
+ chip,
+ gridPosition = 'bottom-left',
+ } = opts;
return (
@@ -30,7 +38,7 @@ export default function Hero(opts: {
)}
{children}
-
+
{/* Fade-out gradient overlay */}
diff --git a/src/components/ui/icons/s-pulse-2.tsx b/src/components/ui/icons/s-pulse-2.tsx
new file mode 100644
index 000000000..4fa5143d4
--- /dev/null
+++ b/src/components/ui/icons/s-pulse-2.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+
+type iconProps = {
+ fill?: string,
+ secondaryfill?: string,
+ strokewidth?: number,
+ width?: string,
+ height?: string,
+ title?: string
+}
+
+function SPulse2(props: iconProps) {
+ const fill = props.fill || 'currentColor';
+ const secondaryfill = props.secondaryfill || fill;
+ const strokewidth = props.strokewidth || 1;
+ const width = props.width || '1em';
+ const height = props.height || '1em';
+ const title = props.title || "s pulse 2";
+
+ return (
+
+ {title}
+
+
+
+
+
+ );
+};
+
+export default SPulse2;
\ No newline at end of file
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
index e93d2274d..3c7e57929 100644
--- a/src/components/ui/select.tsx
+++ b/src/components/ui/select.tsx
@@ -26,7 +26,7 @@ const SelectTrigger = React.forwardRef<
>
{children}
-
+
));
diff --git a/src/hooks/use-on-window-resize.ts b/src/hooks/use-on-window-resize.ts
new file mode 100644
index 000000000..d1eedcde7
--- /dev/null
+++ b/src/hooks/use-on-window-resize.ts
@@ -0,0 +1,15 @@
+// Tremor useOnWindowResize [v0.0.0]
+
+import * as React from 'react';
+
+export const useOnWindowResize = (handler: { (): void }) => {
+ React.useEffect(() => {
+ const handleResize = () => {
+ handler();
+ };
+ handleResize();
+ window.addEventListener('resize', handleResize);
+
+ return () => window.removeEventListener('resize', handleResize);
+ }, [handler]);
+};
diff --git a/src/lib/chart-utils.ts b/src/lib/chart-utils.ts
new file mode 100644
index 000000000..797a78845
--- /dev/null
+++ b/src/lib/chart-utils.ts
@@ -0,0 +1,126 @@
+// Tremor chartColors [v0.1.0]
+
+export type ColorUtility = 'bg' | 'stroke' | 'fill' | 'text';
+
+export const chartColors = {
+ blue: {
+ bg: 'bg-blue-500',
+ stroke: 'stroke-blue-500',
+ fill: 'fill-blue-500',
+ text: 'text-blue-500',
+ },
+ emerald: {
+ bg: 'bg-emerald-500',
+ stroke: 'stroke-emerald-500',
+ fill: 'fill-emerald-500',
+ text: 'text-emerald-500',
+ },
+ violet: {
+ bg: 'bg-violet-500',
+ stroke: 'stroke-violet-500',
+ fill: 'fill-violet-500',
+ text: 'text-violet-500',
+ },
+ amber: {
+ bg: 'bg-amber-500',
+ stroke: 'stroke-amber-500',
+ fill: 'fill-amber-500',
+ text: 'text-amber-500',
+ },
+ gray: {
+ bg: 'bg-gray-500',
+ stroke: 'stroke-gray-500',
+ fill: 'fill-gray-500',
+ text: 'text-gray-500',
+ },
+ cyan: {
+ bg: 'bg-cyan-500',
+ stroke: 'stroke-cyan-500',
+ fill: 'fill-cyan-500',
+ text: 'text-cyan-500',
+ },
+ pink: {
+ bg: 'bg-pink-500',
+ stroke: 'stroke-pink-500',
+ fill: 'fill-pink-500',
+ text: 'text-pink-500',
+ },
+ lime: {
+ bg: 'bg-lime-500',
+ stroke: 'stroke-lime-500',
+ fill: 'fill-lime-500',
+ text: 'text-lime-500',
+ },
+ fuchsia: {
+ bg: 'bg-fuchsia-500',
+ stroke: 'stroke-fuchsia-500',
+ fill: 'fill-fuchsia-500',
+ text: 'text-fuchsia-500',
+ },
+ accent: {
+ bg: 'bg-accent',
+ stroke: 'stroke-accent',
+ fill: 'fill-accent',
+ text: 'text-accent',
+ },
+} as const satisfies {
+ [color: string]: {
+ [key in ColorUtility]: string;
+ };
+};
+
+export type AvailableChartColorsKeys = keyof typeof chartColors;
+
+export const AvailableChartColors: AvailableChartColorsKeys[] = Object.keys(
+ chartColors
+) as Array
;
+
+export const constructCategoryColors = (
+ categories: string[],
+ colors: AvailableChartColorsKeys[]
+): Map => {
+ const categoryColors = new Map();
+ categories.forEach((category, index) => {
+ categoryColors.set(category, colors[index % colors.length]);
+ });
+ return categoryColors;
+};
+
+export const getColorClassName = (color: AvailableChartColorsKeys, type: ColorUtility): string => {
+ const fallbackColor = {
+ bg: 'bg-gray-500',
+ stroke: 'stroke-gray-500',
+ fill: 'fill-gray-500',
+ text: 'text-gray-500',
+ };
+ return chartColors[color]?.[type] ?? fallbackColor[type];
+};
+
+// Tremor getYAxisDomain [v0.0.0]
+
+export const getYAxisDomain = (
+ autoMinValue: boolean,
+ minValue: number | undefined,
+ maxValue: number | undefined
+) => {
+ const minDomain = autoMinValue ? 'auto' : minValue ?? 0;
+ const maxDomain = maxValue ?? 'auto';
+ return [minDomain, maxDomain];
+};
+
+// Tremor hasOnlyOneValueForKey [v0.1.0]
+
+export function hasOnlyOneValueForKey(array: any[], keyToCheck: string): boolean {
+ const val: any[] = [];
+
+ for (const obj of array) {
+ if (Object.prototype.hasOwnProperty.call(obj, keyToCheck)) {
+ val.push(obj[keyToCheck]);
+ if (val.length > 1) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index 2819a830d..fd265b6b3 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -4,3 +4,12 @@ import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+
+// Tremor focusRing [v0.0.1]
+
+export const focusRing = [
+ // base
+ 'outline outline-offset-2 outline-0 focus-visible:outline-2',
+ // outline color
+ 'outline-black-50 dark:outline-black-50',
+];
diff --git a/src/types/Stats.ts b/src/types/Stats.ts
index dc06f8ef3..4ab92a79b 100644
--- a/src/types/Stats.ts
+++ b/src/types/Stats.ts
@@ -1,12 +1,18 @@
import { STEPS } from '@/utils/constants';
-import { StatisticsReport } from '@prisma/client';
+import { StatisticsReport, QuestionDifficulty } from '@prisma/client';
import type { Question } from '@/types';
+// Record of difficulty types with their counts
+export type DifficultyRecord = {
+ [K in QuestionDifficulty]?: number;
+};
+
export type StatsChartData = {
[key: string]: {
totalQuestions: number;
tagCounts: Record;
tags: string[];
+ difficulties?: DifficultyRecord;
};
};
diff --git a/src/utils/data/answers/get-answer.ts b/src/utils/data/answers/get-answer.ts
new file mode 100644
index 000000000..61eb3114b
--- /dev/null
+++ b/src/utils/data/answers/get-answer.ts
@@ -0,0 +1,23 @@
+import { prisma } from '@/lib/prisma';
+
+/**
+ * Get the correct answer for a given question
+ *
+ * @param opts
+ * @returns
+ */
+export const getAnswer = async (opts: { questionUid: string }) => {
+ const { questionUid } = opts;
+
+ try {
+ // find the answer to the question
+ return await prisma.questionAnswers.findFirst({
+ where: {
+ questionUid,
+ },
+ });
+ } catch (error) {
+ console.error(error);
+ throw new Error('Error getting user answer');
+ }
+};
diff --git a/src/utils/data/answers/get-user-answer.ts b/src/utils/data/answers/get-user-answer.ts
index e4d7faf12..ba5b35061 100644
--- a/src/utils/data/answers/get-user-answer.ts
+++ b/src/utils/data/answers/get-user-answer.ts
@@ -31,3 +31,44 @@ export const getUserAnswer = async (opts: { questionUid: string }) => {
throw new Error('Could not fetch user answer. Please try again later.'); // Handle the error gracefully
}
};
+
+export interface RecentUserAnswer {
+ uid: string;
+ correctAnswer: boolean;
+ question: {
+ title: string | null;
+ slug: string | null;
+ question: string | null;
+ };
+ createdAt: Date;
+}
+
+export const getRecentUserAnswers = async ({ take = 10 }: { take?: number }) => {
+ const user = await getUser();
+
+ if (!user) {
+ return [];
+ }
+
+ return await prisma.answers.findMany({
+ where: {
+ userUid: user.uid,
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ take: 10,
+ select: {
+ correctAnswer: true,
+ question: {
+ select: {
+ title: true,
+ slug: true,
+ question: true,
+ },
+ },
+ createdAt: true,
+ uid: true,
+ },
+ });
+};
diff --git a/src/utils/data/statistics/get-stats-chart-data.ts b/src/utils/data/statistics/get-stats-chart-data.ts
index c02b8ea33..19991c9f1 100644
--- a/src/utils/data/statistics/get-stats-chart-data.ts
+++ b/src/utils/data/statistics/get-stats-chart-data.ts
@@ -13,17 +13,20 @@ import { revalidateTag } from 'next/cache';
const getStatsChartData = async (opts: {
userUid: string;
to: string;
- from: StatsSteps;
- step: 'month' | 'week' | 'day';
+ from: StatsSteps | 'all';
+ step?: 'month' | 'week' | 'day';
+ separateByDifficulty?: boolean;
+ includeDifficultyData?: boolean;
}) => {
- const { userUid, to, from, step } = opts;
+ const { userUid, to, from, step, separateByDifficulty, includeDifficultyData } = opts;
if (!userUid) {
return null;
}
const toDate = new Date(to);
- const fromDate = getRange(from);
+ // If 'all' is specified, use a very old date to get all data
+ const fromDate = from === 'all' ? new Date(0) : getRange(from);
const questions = await prisma.answers.findMany({
where: {
@@ -41,11 +44,60 @@ const getStatsChartData = async (opts: {
tag: true,
},
},
+ difficulty: includeDifficultyData, // only include difficulty if requested
},
},
},
});
+ // If no step is provided, return ungrouped data
+ if (!step) {
+ // Create an overall stats object
+ const ungroupedData: StatsChartData = {
+ all: {
+ totalQuestions: 0,
+ tagCounts: {},
+ tags: [],
+ difficulties: includeDifficultyData
+ ? {
+ BEGINNER: 0,
+ EASY: 0,
+ MEDIUM: 0,
+ HARD: 0,
+ }
+ : undefined,
+ },
+ };
+
+ // Process all questions without time-based grouping
+ questions.forEach((answer) => {
+ const tags = answer.question.tags.map((tag) => tag.tag.name);
+ ungroupedData['all'].totalQuestions++;
+
+ // Track difficulty if needed
+ if (
+ includeDifficultyData &&
+ ungroupedData['all'].difficulties &&
+ answer.question.difficulty
+ ) {
+ const difficulty = answer.question.difficulty;
+ if (ungroupedData['all'].difficulties[difficulty] !== undefined) {
+ ungroupedData['all'].difficulties[difficulty]!++;
+ }
+ }
+
+ // Process tags
+ tags.forEach((tag) => {
+ ungroupedData['all'].tagCounts[tag] = (ungroupedData['all'].tagCounts[tag] || 0) + 1;
+ if (!ungroupedData['all'].tags.includes(tag)) {
+ ungroupedData['all'].tags.push(tag);
+ }
+ });
+ });
+
+ return ungroupedData;
+ }
+
const data: StatsChartData = {};
// Generate all dates in range, excluding the fromDate
@@ -75,6 +127,14 @@ const getStatsChartData = async (opts: {
totalQuestions: 0,
tagCounts: {},
tags: [],
+ difficulties: includeDifficultyData
+ ? {
+ BEGINNER: 0,
+ EASY: 0,
+ MEDIUM: 0,
+ HARD: 0,
+ }
+ : undefined,
};
// Increment date based on step
@@ -91,7 +151,7 @@ const getStatsChartData = async (opts: {
}
}
- // Fill in actual data
+ // fill in actual data
questions.forEach((answer) => {
let key: string;
const year = answer.createdAt.getFullYear();
@@ -113,6 +173,16 @@ const getStatsChartData = async (opts: {
if (data[key]) {
const tags = answer.question.tags.map((tag) => tag.tag.name);
data[key].totalQuestions++;
+
+ // Track difficulty if needed
+ if (includeDifficultyData && data[key]?.difficulties && answer.question.difficulty) {
+ const difficulty = answer.question.difficulty;
+ const diffObj = data[key].difficulties;
+ if (diffObj && typeof diffObj === 'object' && difficulty in diffObj) {
+ diffObj[difficulty] = (diffObj[difficulty] || 0) + 1;
+ }
+ }
+
tags.forEach((tag) => {
data[key].tagCounts[tag] = (data[key].tagCounts[tag] || 0) + 1;
if (!data[key].tags.includes(tag)) {
@@ -122,6 +192,25 @@ const getStatsChartData = async (opts: {
}
});
+ if (separateByDifficulty) {
+ // separate by difficulty
+ const difficultyData: StatsChartData = {};
+ Object.keys(data).forEach((key) => {
+ const tags = data[key].tags;
+ tags.forEach((tag) => {
+ if (!difficultyData[tag]) {
+ difficultyData[tag] = {
+ totalQuestions: 0,
+ tagCounts: {},
+ tags: [],
+ };
+ }
+ difficultyData[tag].totalQuestions += data[key].totalQuestions;
+ });
+ });
+ return difficultyData;
+ }
+
revalidateTag('statistics');
return data;
@@ -130,13 +219,13 @@ const getStatsChartData = async (opts: {
/**
* Gets the total number of questions the user has answered within a specific range.
*/
-const getTotalQuestionCount = async (userUid: string, to: string, from: StatsSteps) => {
+const getTotalQuestionCount = async (userUid: string, to: string, from: StatsSteps | 'all') => {
if (!userUid) {
return null;
}
const toDate = new Date(to);
- const fromDate = getRange(from);
+ const fromDate = from === 'all' ? new Date(0) : getRange(from);
const questions = await prisma.answers.count({
where: {
@@ -156,7 +245,11 @@ const getTotalQuestionCount = async (userUid: string, to: string, from: StatsSte
/**
* Gets the total time taken for questions answered within a specific range.
*/
-export const getTotalTimeTaken = async (userUid: string, to?: string, from?: StatsSteps) => {
+export const getTotalTimeTaken = async (
+ userUid: string,
+ to?: string,
+ from?: StatsSteps | 'all'
+) => {
if (!userUid) {
return null;
}
@@ -171,7 +264,7 @@ export const getTotalTimeTaken = async (userUid: string, to?: string, from?: Sta
}
const toDate = new Date(to || new Date().toISOString());
- const fromDate = getRange(from || '7d');
+ const fromDate = from === 'all' ? new Date(0) : getRange(from || '7d');
const answers = await prisma.answers.findMany({
where: {
@@ -196,13 +289,13 @@ export const getTotalTimeTaken = async (userUid: string, to?: string, from?: Sta
/**
* Gets the highest scoring tag within a specific range.
*/
-const getHighestScoringTag = async (userUid: string, to: string, from: StatsSteps) => {
+const getHighestScoringTag = async (userUid: string, to: string, from: StatsSteps | 'all') => {
if (!userUid) {
return null;
}
const toDate = new Date(to);
- const fromDate = getRange(from);
+ const fromDate = from === 'all' ? new Date(0) : getRange(from);
const answers = await prisma.answers.findMany({
where: {
@@ -250,17 +343,19 @@ const getHighestScoringTag = async (userUid: string, to: string, from: StatsStep
export const getData = async (opts: {
userUid: string;
to: string;
- from: StatsSteps;
- step: 'month' | 'week' | 'day';
+ from: StatsSteps | 'all';
+ step?: 'month' | 'week' | 'day';
+ separateByDifficulty?: boolean;
+ includeDifficultyData?: boolean;
}) => {
- const { userUid, to, from } = opts;
+ const { userUid, to, from, step, separateByDifficulty, includeDifficultyData = false } = opts;
// run all in parallel as they do not depend on each other
const [stats, totalQuestions, totalTimeTaken, highestScoringTag] = await Promise.all([
- getStatsChartData(opts),
- getTotalQuestionCount(userUid, to, from),
- getTotalTimeTaken(userUid, to, from),
- getHighestScoringTag(userUid, to, from),
+ getStatsChartData({ ...opts, includeDifficultyData }),
+ getTotalQuestionCount(userUid, to, from === 'all' ? '90d' : from), // Default to 90d for count if 'all' is used
+ getTotalTimeTaken(userUid, to, from === 'all' ? '90d' : from), // Default to 90d for time if 'all' is used
+ getHighestScoringTag(userUid, to, from === 'all' ? '90d' : from), // Default to 90d for tags if 'all' is used
]);
return { stats, totalQuestions, totalTimeTaken, highestScoringTag };
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 41d1eee2f..ff81f4ba4 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -1,6 +1,6 @@
import { Filter } from 'bad-words';
-import type { StatsChartData } from '@/components/app/statistics/total-question-chart';
+import type { StatsChartData } from '@/components/charts/total-question-chart';
import { UserExperienceLevel } from '@prisma/client';
import type { QuestionDifficulty } from '@/types';
import { toast } from 'sonner';
diff --git a/tailwind.config.ts b/tailwind.config.ts
index e731f0874..64ea14b23 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -257,8 +257,25 @@ function addVariablesForColors({ addBase, theme }: any) {
Object.entries(allColors).map(([key, val]) => [`--${key}`, val])
);
+ // Custom accent utilities
+ const customUtilities = {
+ '.stroke-accent': {
+ stroke: 'hsl(var(--accent))',
+ },
+ '.fill-accent': {
+ fill: 'hsl(var(--accent))',
+ },
+ '.bg-accent': {
+ backgroundColor: 'hsl(var(--accent))',
+ },
+ '.text-accent': {
+ color: 'hsl(var(--accent))',
+ },
+ };
+
addBase({
':root': newVars,
+ ...customUtilities,
});
}