diff --git a/.storybook/README.md b/.storybook/README.md new file mode 100644 index 000000000..6d478157b --- /dev/null +++ b/.storybook/README.md @@ -0,0 +1,19 @@ +# Server Components with Storybook + +This document provides guidance on how to use server components with Storybook in our Next.js App Router application. + +## The Challenge + +Storybook does not natively support React Server Components (RSC). When trying to use server components directly in Storybook, you'll encounter errors because: + +1. Server components cannot be imported directly into client components +2. Server-side data fetching methods don't work in the Storybook environment +3. Async components are not supported in the current Storybook setup + +## Our Solution + +We use a simple pattern to render server components in Storybook: + +1. Wrap the server component in a Suspense boundary +2. Import the server component directly in the story file +3. Let the experimental RSC support in Storybook handle the rendering diff --git a/.storybook/main.ts b/.storybook/main.ts index aba7b418a..0238036db 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -12,5 +12,8 @@ const config: StorybookConfig = { name: '@storybook/experimental-nextjs-vite', options: {}, }, + features: { + experimentalRSC: true, + }, }; export default config; diff --git a/package.json b/package.json index 7af1cc1d3..b8df04de5 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.3", "@react-email/components": "0.0.31", + "@remixicon/react": "4.6.0", "@stripe/react-stripe-js": "^2.8.1", "@stripe/stripe-js": "^4.8.0", "@supabase/ssr": "^0.5.1", diff --git a/src/app/(app)/(default_layout)/layout.tsx b/src/app/(app)/(default_layout)/layout.tsx index c0eed03cf..07d011f00 100644 --- a/src/app/(app)/(default_layout)/layout.tsx +++ b/src/app/(app)/(default_layout)/layout.tsx @@ -12,7 +12,7 @@ export default function StatisticsLayout({ children }: Readonly<{ children: Reac -
{children}
+ {children} ); } diff --git a/src/app/(app)/(default_layout)/statistics/page.tsx b/src/app/(app)/(default_layout)/statistics/page.tsx index 13ae592da..cdffe7fba 100644 --- a/src/app/(app)/(default_layout)/statistics/page.tsx +++ b/src/app/(app)/(default_layout)/statistics/page.tsx @@ -1,22 +1,42 @@ -import StatsRangePicker from '@/components/app/statistics/range-picker'; -import QuestionChart from '@/components/app/statistics/total-question-chart'; +import dynamic from 'next/dynamic'; -import { useUserServer } from '@/hooks/use-user-server'; -import { StatsSteps } from '@/types'; +import StatsRangePicker from '@/components/app/statistics/range-picker'; +import QuestionChart from '@/components/charts/total-question-chart'; +import QuestionHistory from '@/components/app/statistics/question-history'; -import { STATISTICS } from '@/utils/constants'; +const DifficultyRadialChart = dynamic( + () => import('@/components/app/statistics/difficulty-radial-chart'), + { ssr: false } +); -import { getData } from '@/utils/data/statistics/get-stats-chart-data'; import Hero from '@/components/shared/hero'; import SuggestedQuestions from '@/components/app/statistics/suggested-questions'; -import StatisticsReport from '@/components/app/statistics/statistics-report'; import StatisticsOverviewMenu from '@/components/app/statistics/statistics-overview-menu'; +import StatisticsReport from '@/components/app/statistics/statistics-report'; import QuestionTracker from '@/components/app/statistics/question-tracker'; -export const metadata = { - title: 'Statistics | techblitz', - description: 'View your coding statistics and progress', -}; +import { useUserServer } from '@/hooks/use-user-server'; +import { StatsSteps } from '@/types'; + +import { STATISTICS } from '@/utils/constants'; +import { getData } from '@/utils/data/statistics/get-stats-chart-data'; +import { createMetadata } from '@/utils/seo'; +import { getUserDisplayName } from '@/utils/user'; +import { getRecentUserAnswers } from '@/utils/data/answers/get-user-answer'; + +export async function generateMetadata() { + return createMetadata({ + title: 'Statistics | TechBlitz', + description: + 'Dive into your current coding journey, track your progress, and gain insight on how to improve your skills.', + image: { + text: 'Statistics | TechBlitz', + bgColor: '#000', + textColor: '#fff', + }, + canonicalUrl: '/statistics', + }); +} export default async function StatisticsPage({ searchParams, @@ -33,36 +53,45 @@ export default async function StatisticsPage({ const range = (searchParams.range as StatsSteps) || '7d'; const { step } = STATISTICS[range]; - // Prefetch data - const { stats } = await getData({ - userUid: user.uid, - from: range, - to: new Date().toISOString(), - step, - }); + // Prefetch data - get both time-grouped and overall stats + const [timeGroupedStats, overallStats, recentAnswers] = await Promise.all([ + getData({ + userUid: user.uid, + from: range, + to: new Date().toISOString(), + step, + includeDifficultyData: true, + }), + getData({ + userUid: user.uid, + from: 'all', + to: new Date().toISOString(), + includeDifficultyData: true, + }), + getRecentUserAnswers({ take: 10 }), + ]); return ( -
-
+
+
-
- - -
+ {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 +

+ +
+ )} +
+ ); +} 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 ( + + + + + +

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.

    + +
    + ) : ( +
      + {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 ? ( - - ) : ( - - )} -
    - -
    -
    -
    - - 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 ( + + ); +}; + +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 ( +
    +
    +

    + {label} +

    +
    +
    + {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 && ( + + )} + + + {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', + ] + : '' + )} + > +
    +
    + {item.href ? ( + event.stopPropagation()} + > + {item.name} + + + ) : ( +

    + {item.name} +

    + )} +
    +
    +
    + ))} +
    +
    + {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 ( + + ); +}; + +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 ( +
    +
    +

    + {label} +

    +
    +
    + {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 && ( + + )} + + + {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) => ( +
    +
    +

    Chart

    + +
    +
    +); + +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) => ( +
    +
    +

    Chart

    + +
    +
    +); + +// 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) => ( +
    +
    +

    Chart

    + +
    +
    +); + +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 */} +
    + +
    +
    + + + {/* Check if there's data to display */} + {orderedChartData.length > 0 ? ( + } + tickGap={20} + connectNulls + autoMinValue + /> + ) : ( +
    +

    No data available

    +
    + )} +
    +
    +
    +
    + {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, }); }