From 87e33eda4a0a958e70d53d1a3aa918c6bcfa1afc Mon Sep 17 00:00:00 2001 From: Logan Ford <110533855+Logannford@users.noreply.github.com> Date: Mon, 24 Mar 2025 21:00:02 +0000 Subject: [PATCH 01/28] feat: init stats page refresh! --- .../(default_layout)/statistics/page.tsx | 42 ++--- .../statistics/difficulty-radial-chart.tsx | 147 ++++++++++++++++++ src/types/Stats.ts | 8 +- .../data/statistics/get-stats-chart-data.ts | 31 +++- 4 files changed, 205 insertions(+), 23 deletions(-) create mode 100644 src/components/app/statistics/difficulty-radial-chart.tsx diff --git a/src/app/(app)/(default_layout)/statistics/page.tsx b/src/app/(app)/(default_layout)/statistics/page.tsx index a1b9d0a01..adcae7a4b 100644 --- a/src/app/(app)/(default_layout)/statistics/page.tsx +++ b/src/app/(app)/(default_layout)/statistics/page.tsx @@ -1,5 +1,6 @@ import StatsRangePicker from '@/components/app/statistics/range-picker'; import QuestionChart from '@/components/app/statistics/total-question-chart'; +import DifficultyRadialChart from '@/components/app/statistics/difficulty-radial-chart'; import { useUserServer } from '@/hooks/use-user-server'; import { StatsSteps } from '@/types/Stats'; @@ -12,11 +13,21 @@ 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 QuestionTracker from '@/components/app/statistics/question-tracker'; +import { createMetadata } from '@/utils/seo'; +import { getUserDisplayName } from '@/utils/user'; -export const metadata = { - title: 'Statistics | techblitz', - description: 'View your coding statistics and progress', -}; +export async function generateMetadata() { + return createMetadata({ + title: 'Statistics | techblitz', + description: 'View your coding statistics and progress', + image: { + text: 'Statistics | techblitz', + bgColor: '#000', + textColor: '#fff', + }, + canonicalUrl: '/statistics', + }); +} export default async function StatisticsPage({ searchParams, @@ -33,36 +44,31 @@ export default async function StatisticsPage({ const range = (searchParams.range as StatsSteps) || '7d'; const { step } = STATISTICS[range]; - // Prefetch data + // Prefetch data with includeDifficultyData flag const { stats } = await getData({ userUid: user.uid, from: range, to: new Date().toISOString(), step, + includeDifficultyData: true, // Make sure to include difficulty data }); return (
-
- - -
+ {stats && ( + + )}
-
- {stats && } -
- {stats && } - {/** suggested q's and analysis blocks TODO: CHANGE SUGGESTED QUESTIONS TO STREAK DATA (I THINK) */} - - + {/* Radial Difficulty Chart */} +
); 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..4b4f4f8fd --- /dev/null +++ b/src/components/app/statistics/difficulty-radial-chart.tsx @@ -0,0 +1,147 @@ +'use client'; + +import { useMemo } from 'react'; +import { RadialBarChart, RadialBar, Legend, ResponsiveContainer, Tooltip } from 'recharts'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; +import { StatsChartData, DifficultyRecord } from '@/types/Stats'; +import { Button } from '@/components/ui/button'; + +// Define colors for each difficulty +const DIFFICULTY_COLORS = { + BEGINNER: 'hsl(var(--chart-1))', // blue + EASY: 'hsl(var(--chart-2))', // green + MEDIUM: 'hsl(var(--chart-3))', // yellow + HARD: 'hsl(var(--chart-4))', // red +}; + +// Map difficulty to friendly names +const DIFFICULTY_LABELS = { + BEGINNER: 'Beginner', + EASY: 'Easy', + MEDIUM: 'Medium', + HARD: 'Hard', +}; + +export default function DifficultyRadialChart({ + questionData, + step, + backgroundColor, +}: { + questionData: StatsChartData; + step: 'day' | 'week' | 'month'; + backgroundColor?: string; +}) { + // 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) => { + // Higher index means smaller inner radius for the radial bar + 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]); + + // Custom Legend that shows the count and percentage + const CustomizedLegend = ({ payload }: any) => { + if (!payload || payload.length === 0) return null; + + return ( + + ); + }; + + return ( + <> + {difficultyData.grandTotal > 0 ? ( +
+ + + + } + /> + [ + `${value} (${((value / difficultyData.grandTotal) * 100).toFixed(1)}%)`, + 'Questions', + ]} + contentStyle={{ + backgroundColor: 'hsl(var(--background))', + borderColor: 'hsl(var(--border))', + }} + itemStyle={{ color: 'hsl(var(--foreground))' }} + labelStyle={{ color: 'hsl(var(--foreground))' }} + /> + + +
+ ) : ( +
+

+ No difficulty data available due to lack of questions answered +

+ +
+ )} + + ); +} diff --git a/src/types/Stats.ts b/src/types/Stats.ts index 9dc1d011b..bc256f32f 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 { Question } from '@/types/Questions'; +// 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/statistics/get-stats-chart-data.ts b/src/utils/data/statistics/get-stats-chart-data.ts index 49d2d3672..fd500c5cf 100644 --- a/src/utils/data/statistics/get-stats-chart-data.ts +++ b/src/utils/data/statistics/get-stats-chart-data.ts @@ -15,8 +15,10 @@ const getStatsChartData = async (opts: { to: string; from: StatsSteps; 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; @@ -91,7 +93,7 @@ const getStatsChartData = async (opts: { } } - // Fill in actual data + // fill in actual data questions.forEach((answer) => { let key: string; const year = answer.createdAt.getFullYear(); @@ -122,6 +124,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; @@ -252,12 +273,14 @@ export const getData = async (opts: { to: string; from: StatsSteps; step: 'month' | 'week' | 'day'; + separateByDifficulty?: boolean; + includeDifficultyData?: boolean; }) => { - const { userUid, to, from } = opts; + const { userUid, to, from, 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), + getStatsChartData({ ...opts, includeDifficultyData }), getTotalQuestionCount(userUid, to, from), getTotalTimeTaken(userUid, to, from), getHighestScoringTag(userUid, to, from), From b3727f0bec2ad0325a95a3ab06ffc0e7f9638edc Mon Sep 17 00:00:00 2001 From: Logan Ford <110533855+Logannford@users.noreply.github.com> Date: Mon, 24 Mar 2025 21:39:04 +0000 Subject: [PATCH 02/28] feat: work on stats page hero component --- .../(default_layout)/statistics/page.tsx | 46 ++++---- .../statistics/difficulty-radial-chart.tsx | 94 +++++++--------- .../data/statistics/get-stats-chart-data.ts | 102 +++++++++++++++--- 3 files changed, 152 insertions(+), 90 deletions(-) diff --git a/src/app/(app)/(default_layout)/statistics/page.tsx b/src/app/(app)/(default_layout)/statistics/page.tsx index adcae7a4b..e561a9b1a 100644 --- a/src/app/(app)/(default_layout)/statistics/page.tsx +++ b/src/app/(app)/(default_layout)/statistics/page.tsx @@ -18,10 +18,11 @@ import { getUserDisplayName } from '@/utils/user'; export async function generateMetadata() { return createMetadata({ - title: 'Statistics | techblitz', - description: 'View your coding statistics and progress', + 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', + text: 'Statistics | TechBlitz', bgColor: '#000', textColor: '#fff', }, @@ -44,31 +45,32 @@ export default async function StatisticsPage({ const range = (searchParams.range as StatsSteps) || '7d'; const { step } = STATISTICS[range]; - // Prefetch data with includeDifficultyData flag - const { stats } = await getData({ - userUid: user.uid, - from: range, - to: new Date().toISOString(), - step, - includeDifficultyData: true, // Make sure to include difficulty data - }); + // Prefetch data - get both time-grouped and overall stats + const [timeGroupedStats, overallStats] = 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, + }), + ]); return ( -
-
+
+
- {stats && ( - - )} -
- -
- {/* Radial Difficulty Chart */} -
+ {overallStats.stats && }
); diff --git a/src/components/app/statistics/difficulty-radial-chart.tsx b/src/components/app/statistics/difficulty-radial-chart.tsx index 4b4f4f8fd..5b256d6a9 100644 --- a/src/components/app/statistics/difficulty-radial-chart.tsx +++ b/src/components/app/statistics/difficulty-radial-chart.tsx @@ -2,17 +2,16 @@ import { useMemo } from 'react'; import { RadialBarChart, RadialBar, Legend, ResponsiveContainer, Tooltip } from 'recharts'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { cn } from '@/lib/utils'; -import { StatsChartData, DifficultyRecord } from '@/types/Stats'; +import { StatsChartData } from '@/types/Stats'; import { Button } from '@/components/ui/button'; // Define colors for each difficulty +// Use the same difficulty colors as defined in the app's utility functions const DIFFICULTY_COLORS = { - BEGINNER: 'hsl(var(--chart-1))', // blue - EASY: 'hsl(var(--chart-2))', // green - MEDIUM: 'hsl(var(--chart-3))', // yellow - HARD: 'hsl(var(--chart-4))', // red + BEGINNER: '#3b82f6', // blue-500 + EASY: '#22c55e', // green-500 + MEDIUM: '#eab308', // yellow-500 + HARD: '#ef4444', // red-500 }; // Map difficulty to friendly names @@ -23,15 +22,7 @@ const DIFFICULTY_LABELS = { HARD: 'Hard', }; -export default function DifficultyRadialChart({ - questionData, - step, - backgroundColor, -}: { - questionData: StatsChartData; - step: 'day' | 'week' | 'month'; - backgroundColor?: string; -}) { +export default function DifficultyRadialChart({ questionData }: { questionData: StatsChartData }) { // Calculate total questions by difficulty const difficultyData = useMemo(() => { // Create object to store totals by difficulty @@ -57,7 +48,7 @@ export default function DifficultyRadialChart({ .filter(([_, count]) => count > 0) // Only include non-zero counts .sort((a, b) => b[1] - a[1]) // Sort by count (descending) .map(([difficulty, count], index) => { - // Higher index means smaller inner radius for the radial bar + // Calculate the angle for the radial chart return { name: DIFFICULTY_LABELS[difficulty as keyof typeof DIFFICULTY_LABELS] || difficulty, value: count, @@ -70,72 +61,69 @@ export default function DifficultyRadialChart({ return { chartData, grandTotal }; }, [questionData]); - // Custom Legend that shows the count and percentage - const CustomizedLegend = ({ payload }: any) => { - if (!payload || payload.length === 0) return null; - + // Generate a legend that shows both counts and percentages + const LegendContent = () => { return ( -
    - {payload.map((entry: any, index: number) => ( -
  • -
    - {entry.value} - - {difficultyData.chartData.find((item) => item.name === entry.value)?.value || 0}{' '} - questions ( - {difficultyData.chartData.find((item) => item.name === entry.value)?.percentage || 0} - %) - -
  • +
    + {difficultyData.chartData.map((entry, index) => ( +
    +
    +
    + {entry.name} + + {entry.value} ({entry.percentage}%) + +
    +
    ))} -
+
); }; return ( <> {difficultyData.grandTotal > 0 ? ( -
+
value, + }} dataKey="value" /> - } - /> [ `${value} (${((value / difficultyData.grandTotal) * 100).toFixed(1)}%)`, 'Questions', ]} contentStyle={{ - backgroundColor: 'hsl(var(--background))', - borderColor: 'hsl(var(--border))', + backgroundColor: '#090909', + borderColor: '#2d2d2d', + borderRadius: '6px', }} - itemStyle={{ color: 'hsl(var(--foreground))' }} - labelStyle={{ color: 'hsl(var(--foreground))' }} + itemStyle={{ color: '#fff' }} + labelStyle={{ color: '#fff' }} />
) : ( -
+

No difficulty data available due to lack of questions answered

diff --git a/src/utils/data/statistics/get-stats-chart-data.ts b/src/utils/data/statistics/get-stats-chart-data.ts index fd500c5cf..91ce14814 100644 --- a/src/utils/data/statistics/get-stats-chart-data.ts +++ b/src/utils/data/statistics/get-stats-chart-data.ts @@ -13,8 +13,8 @@ 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; }) => { @@ -25,7 +25,8 @@ const getStatsChartData = async (opts: { } 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: { @@ -43,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 @@ -77,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 @@ -115,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)) { @@ -151,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: { @@ -177,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; } @@ -192,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: { @@ -217,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: { @@ -271,19 +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, separateByDifficulty, includeDifficultyData = false } = 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, includeDifficultyData }), - getTotalQuestionCount(userUid, to, from), - getTotalTimeTaken(userUid, to, from), - getHighestScoringTag(userUid, to, from), + 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 }; From 7f54d04742a299e9a1420c5a47b3d5ee4ff1dabb Mon Sep 17 00:00:00 2001 From: Logan Ford <110533855+Logannford@users.noreply.github.com> Date: Mon, 24 Mar 2025 21:51:18 +0000 Subject: [PATCH 03/28] feat: stats hero chart tooltip work. --- .../statistics/difficulty-radial-chart.tsx | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/src/components/app/statistics/difficulty-radial-chart.tsx b/src/components/app/statistics/difficulty-radial-chart.tsx index 5b256d6a9..db8168f7a 100644 --- a/src/components/app/statistics/difficulty-radial-chart.tsx +++ b/src/components/app/statistics/difficulty-radial-chart.tsx @@ -4,6 +4,7 @@ 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 @@ -83,6 +84,24 @@ export default function DifficultyRadialChart({ questionData }: { questionData: ); }; + // 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 ? ( @@ -106,19 +125,7 @@ export default function DifficultyRadialChart({ questionData }: { questionData: }} dataKey="value" /> - [ - `${value} (${((value / difficultyData.grandTotal) * 100).toFixed(1)}%)`, - 'Questions', - ]} - contentStyle={{ - backgroundColor: '#090909', - borderColor: '#2d2d2d', - borderRadius: '6px', - }} - itemStyle={{ color: '#fff' }} - labelStyle={{ color: '#fff' }} - /> + } animationDuration={500} />
From 72a8e7899d090c891e7bdf52761f6d0c3c246d19 Mon Sep 17 00:00:00 2001 From: Logan Ford <110533855+Logannford@users.noreply.github.com> Date: Mon, 24 Mar 2025 21:59:46 +0000 Subject: [PATCH 04/28] feat: stats page layout issues, minor changes to hero chart --- src/app/(app)/(default_layout)/layout.tsx | 22 +++++++++---------- .../(default_layout)/statistics/page.tsx | 1 + .../statistics/difficulty-radial-chart.tsx | 4 +++- src/components/shared/hero.tsx | 12 ++++++++-- 4 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/app/(app)/(default_layout)/layout.tsx b/src/app/(app)/(default_layout)/layout.tsx index e7cd670d5..7a17b562c 100644 --- a/src/app/(app)/(default_layout)/layout.tsx +++ b/src/app/(app)/(default_layout)/layout.tsx @@ -5,20 +5,18 @@ import UserXp from '@/components/ui/user-xp'; export default function StatisticsLayout({ children }: Readonly<{ children: React.ReactNode }>) { return ( -
-
-
-
- -
-
- - - -
+
+
+
+ +
+
+ + +
-
{children}
+ {children}
); } diff --git a/src/app/(app)/(default_layout)/statistics/page.tsx b/src/app/(app)/(default_layout)/statistics/page.tsx index e561a9b1a..58f7c194d 100644 --- a/src/app/(app)/(default_layout)/statistics/page.tsx +++ b/src/app/(app)/(default_layout)/statistics/page.tsx @@ -69,6 +69,7 @@ export default async function StatisticsPage({ heading={`${getUserDisplayName(user)}'s Statistics`} container={false} subheading="Dive into your coding journey, track your progress, and gain insight on how to improve your skills." + gridPosition="top-right" /> {overallStats.stats && }
diff --git a/src/components/app/statistics/difficulty-radial-chart.tsx b/src/components/app/statistics/difficulty-radial-chart.tsx index db8168f7a..33a774491 100644 --- a/src/components/app/statistics/difficulty-radial-chart.tsx +++ b/src/components/app/statistics/difficulty-radial-chart.tsx @@ -112,7 +112,7 @@ export default function DifficultyRadialChart({ questionData }: { questionData: cy="50%" innerRadius="10%" outerRadius="80%" - barSize={25} + barSize={17} data={difficultyData.chartData} > value, }} dataKey="value" + startAngle={45} + endAngle={450} /> } animationDuration={500} /> 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 */}
From 77962e61076f2f8f067b904ac4487a5dd40244e9 Mon Sep 17 00:00:00 2001 From: Logan Ford <110533855+Logannford@users.noreply.github.com> Date: Mon, 24 Mar 2025 22:16:23 +0000 Subject: [PATCH 05/28] trying to fix chart svg height --- src/components/app/statistics/difficulty-radial-chart.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/app/statistics/difficulty-radial-chart.tsx b/src/components/app/statistics/difficulty-radial-chart.tsx index 33a774491..2a1207b9d 100644 --- a/src/components/app/statistics/difficulty-radial-chart.tsx +++ b/src/components/app/statistics/difficulty-radial-chart.tsx @@ -105,7 +105,7 @@ export default function DifficultyRadialChart({ questionData }: { questionData: return ( <> {difficultyData.grandTotal > 0 ? ( -
+
Date: Tue, 25 Mar 2025 19:23:16 +0000 Subject: [PATCH 06/28] feat: some minor progress --- .../(default_layout)/statistics/page.tsx | 20 ++++++++++++------- src/lib/question-tour.ts | 1 - 2 files changed, 13 insertions(+), 8 deletions(-) delete mode 100644 src/lib/question-tour.ts diff --git a/src/app/(app)/(default_layout)/statistics/page.tsx b/src/app/(app)/(default_layout)/statistics/page.tsx index 58f7c194d..87c1e1829 100644 --- a/src/app/(app)/(default_layout)/statistics/page.tsx +++ b/src/app/(app)/(default_layout)/statistics/page.tsx @@ -1,18 +1,24 @@ +import dynamic from 'next/dynamic'; + import StatsRangePicker from '@/components/app/statistics/range-picker'; import QuestionChart from '@/components/app/statistics/total-question-chart'; -import DifficultyRadialChart from '@/components/app/statistics/difficulty-radial-chart'; -import { useUserServer } from '@/hooks/use-user-server'; -import { StatsSteps } from '@/types/Stats'; +const DifficultyRadialChart = dynamic( + () => import('@/components/app/statistics/difficulty-radial-chart'), + { ssr: false } +); -import { STATISTICS } from '@/utils/constants'; - -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'; + +import { useUserServer } from '@/hooks/use-user-server'; +import { StatsSteps } from '@/types/Stats'; + +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'; diff --git a/src/lib/question-tour.ts b/src/lib/question-tour.ts deleted file mode 100644 index 0519ecba6..000000000 --- a/src/lib/question-tour.ts +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file From 16c7dbcd983204936cd4bd34d57c0aa31669dddd Mon Sep 17 00:00:00 2001 From: Logan Ford <110533855+Logannford@users.noreply.github.com> Date: Tue, 25 Mar 2025 20:42:25 +0000 Subject: [PATCH 07/28] feat: adds difficulty radial chart story --- .../(default_layout)/statistics/page.tsx | 8 +- .../difficulty-radial-chart.stories.tsx | 402 ++++++++++++++++++ .../statistics/difficulty-radial-chart.tsx | 78 ++-- .../app/statistics/question-history.tsx | 13 + 4 files changed, 471 insertions(+), 30 deletions(-) create mode 100644 src/components/app/statistics/difficulty-radial-chart.stories.tsx create mode 100644 src/components/app/statistics/question-history.tsx diff --git a/src/app/(app)/(default_layout)/statistics/page.tsx b/src/app/(app)/(default_layout)/statistics/page.tsx index 87c1e1829..90079e4d5 100644 --- a/src/app/(app)/(default_layout)/statistics/page.tsx +++ b/src/app/(app)/(default_layout)/statistics/page.tsx @@ -21,6 +21,7 @@ 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 QuestionHistory from '@/components/app/statistics/question-history'; export async function generateMetadata() { return createMetadata({ @@ -77,7 +78,12 @@ export default async function StatisticsPage({ subheading="Dive into your coding journey, track your progress, and gain insight on how to improve your skills." gridPosition="top-right" /> - {overallStats.stats && } + {overallStats.stats && ( + + )} +
+
+
); 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 index 2a1207b9d..fd6e2c049 100644 --- a/src/components/app/statistics/difficulty-radial-chart.tsx +++ b/src/components/app/statistics/difficulty-radial-chart.tsx @@ -23,7 +23,15 @@ const DIFFICULTY_LABELS = { HARD: 'Hard', }; -export default function DifficultyRadialChart({ questionData }: { questionData: StatsChartData }) { +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 @@ -103,34 +111,46 @@ export default function DifficultyRadialChart({ questionData }: { questionData: }; return ( - <> +
{difficultyData.grandTotal > 0 ? ( -
- - - value, - }} - dataKey="value" - startAngle={45} - endAngle={450} - /> - } animationDuration={500} /> - - +
+
+ + + value, + }} + dataKey="value" + startAngle={45} + endAngle={450} + /> + } animationDuration={500} /> + + +
+ + {legend && ( +
+ + +
+ Total: {difficultyData.grandTotal} questions +
+
+ )}
) : (
@@ -140,6 +160,6 @@ export default function DifficultyRadialChart({ questionData }: { questionData:
)} - +
); } diff --git a/src/components/app/statistics/question-history.tsx b/src/components/app/statistics/question-history.tsx new file mode 100644 index 000000000..e0116bdf8 --- /dev/null +++ b/src/components/app/statistics/question-history.tsx @@ -0,0 +1,13 @@ +import { cn } from '@/lib/utils'; + +interface QuestionHistoryProps { + className?: string; +} + +export default function QuestionHistory({ className }: QuestionHistoryProps) { + return ( +
+

Question History

+
+ ); +} From 90667a426b6652d8509900dbd4b68343e134a8d1 Mon Sep 17 00:00:00 2001 From: Logan Ford <110533855+Logannford@users.noreply.github.com> Date: Tue, 25 Mar 2025 21:16:25 +0000 Subject: [PATCH 08/28] feat: work getting RSC's working with storybook --- .storybook/README.md | 134 ++++++++++++++++++ .storybook/ServerComponentStory.tsx | 48 +++++++ .storybook/storybook-components/index.tsx | 103 ++++++++++++++ .../(default_layout)/statistics/page.tsx | 14 +- .../statistics/question-history.stories.tsx | 15 ++ .../app/statistics/question-history.tsx | 104 +++++++++++++- 6 files changed, 411 insertions(+), 7 deletions(-) create mode 100644 .storybook/README.md create mode 100644 .storybook/ServerComponentStory.tsx create mode 100644 .storybook/storybook-components/index.tsx create mode 100644 src/components/app/statistics/question-history.stories.tsx diff --git a/.storybook/README.md b/.storybook/README.md new file mode 100644 index 000000000..4e194cae7 --- /dev/null +++ b/.storybook/README.md @@ -0,0 +1,134 @@ +# 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've implemented a pattern that allows us to create stories for server components without modifying the actual components: + +1. Create client-side story wrappers that mimic the structure and functionality of server components +2. Use mock data instead of actual data fetching +3. Leverage helper functions to reduce boilerplate code + +## How to Use + +### 1. Import the Helper Functions + +```tsx +import { createServerComponentStory } from '../../../.storybook/ServerComponentStory'; +import { StoryDecorator } from '../../../.storybook/storybook-components'; +``` + +### 2. Create a Client-Side Story Component + +Create a component that mimics your server component's structure: + +```tsx +function MyServerComponentStory(props) { + // Define mock data here + const mockData = {...}; + + return ( + + {/* Mimic your server component's UI here */} + + + ); +} +``` + +### 3. Configure the Story + +Use the `createServerComponentStory` helper function: + +```tsx +const meta = createServerComponentStory(MyServerComponentStory, { + title: 'App/YourCategory/YourComponent', +}) satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + // Your story args here + }, +}; +``` + +## Example + +Here's an example for a `QuestionHistory` component: + +```tsx +// question-history.stories.tsx +import { createServerComponentStory } from '../../../../.storybook/ServerComponentStory'; +import { StoryDecorator } from '../../../../.storybook/storybook-components'; + +function QuestionHistoryStory({ hasAnswers = true }) { + // Mock data that resembles what the server component would fetch + const recentAnswers = hasAnswers ? [...mockAnswers] : []; + + return ( + + + {/* UI that matches our server component */} + + Recent Questions + + {/* Component content */} + + + ); +} + +const meta = createServerComponentStory(QuestionHistoryStory, { + title: 'App/Statistics/QuestionHistory', +}); + +export default meta; + +export const Default: Story = { + args: { hasAnswers: true }, +}; + +export const Empty: Story = { + args: { hasAnswers: false }, +}; +``` + +## Mock Authentication and Data + +For components that require authentication or data fetching, use the helpers in `storybook-components/index.tsx`: + +```tsx +import { + StoryDecorator, + MockPrisma, + DefaultMockUser, +} from '../../../.storybook/storybook-components'; + +function AuthenticatedComponentStory() { + return ( + + + + ); +} +``` + +## Advantages of This Approach + +1. Server components remain untouched and can use server-side features +2. Stories are isolated and don't depend on actual backend functionality +3. Consistent styling and behavior across stories +4. Reduced boilerplate code diff --git a/.storybook/ServerComponentStory.tsx b/.storybook/ServerComponentStory.tsx new file mode 100644 index 000000000..cf945aee0 --- /dev/null +++ b/.storybook/ServerComponentStory.tsx @@ -0,0 +1,48 @@ +import React from 'react'; + +/** + * ServerComponentStory is a helper function for creating Storybook stories + * for React Server Components. It allows you to create a client-side wrapper + * around a server component's structure for use in Storybook. + * + * @param Component - The client component that will mimic the server component's structure + * @param options - Options for configuring the story + * @returns A configured Meta object for Storybook + */ +export function createServerComponentStory>( + Component: T, + options: { + title: string; + parameters?: Record; + decorators?: Array<(Story: any) => JSX.Element>; + tags?: string[]; + } +) { + return { + title: options.title, + component: Component, + parameters: { + layout: 'centered', + backgrounds: { + default: 'dark', + values: [{ name: 'dark', value: '#090909' }], + }, + ...options.parameters, + }, + decorators: options.decorators || [ + (Story: any) => ( +
+ +
+ ), + ], + tags: options.tags || ['autodocs'], + }; +} diff --git a/.storybook/storybook-components/index.tsx b/.storybook/storybook-components/index.tsx new file mode 100644 index 000000000..4088e2210 --- /dev/null +++ b/.storybook/storybook-components/index.tsx @@ -0,0 +1,103 @@ +import React from 'react'; + +/** + * MockUser represents a user for authentication in stories + */ +export interface MockUser { + uid: string; + email: string; + name: string; + image?: string; + role?: string; +} + +/** + * AuthContext provides a mock authentication context for stories + */ +export const AuthContext = React.createContext<{ + user: MockUser | null; + isLoading: boolean; +}>({ + user: null, + isLoading: false, +}); + +/** + * AuthProvider wraps components with a mock authentication context + */ +export function AuthProvider({ + children, + user = null, + isLoading = false, +}: { + children: React.ReactNode; + user?: MockUser | null; + isLoading?: boolean; +}) { + return {children}; +} + +/** + * useAuth hook for accessing the mock authentication context + */ +export function useAuth() { + return React.useContext(AuthContext); +} + +/** + * MockPrisma provides mock database methods for stories + */ +export const MockPrisma = { + // Add methods as needed for different stories + answers: { + findMany: async (options: any) => { + // Return mock data based on options + return []; + }, + count: async (options: any) => { + return 0; + }, + }, + questions: { + findMany: async (options: any) => { + return []; + }, + count: async (options: any) => { + return 0; + }, + }, + users: { + findUnique: async (options: any) => { + return null; + }, + }, +}; + +/** + * DefaultMockUser can be used as a standard user in stories + */ +export const DefaultMockUser: MockUser = { + uid: 'user-1', + email: 'user@example.com', + name: 'Test User', + image: 'https://via.placeholder.com/40', + role: 'user', +}; + +/** + * StoryDecorator for wrapping stories with common providers + */ +export function StoryDecorator({ + children, + user = DefaultMockUser, + withAuth = true, +}: { + children: React.ReactNode; + user?: MockUser | null; + withAuth?: boolean; +}) { + if (withAuth) { + return {children}; + } + return <>{children}; +} diff --git a/src/app/(app)/(default_layout)/statistics/page.tsx b/src/app/(app)/(default_layout)/statistics/page.tsx index 90079e4d5..49644e373 100644 --- a/src/app/(app)/(default_layout)/statistics/page.tsx +++ b/src/app/(app)/(default_layout)/statistics/page.tsx @@ -2,6 +2,7 @@ import dynamic from 'next/dynamic'; import StatsRangePicker from '@/components/app/statistics/range-picker'; import QuestionChart from '@/components/app/statistics/total-question-chart'; +import QuestionHistory from '@/components/app/statistics/question-history'; const DifficultyRadialChart = dynamic( () => import('@/components/app/statistics/difficulty-radial-chart'), @@ -21,7 +22,6 @@ 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 QuestionHistory from '@/components/app/statistics/question-history'; export async function generateMetadata() { return createMetadata({ @@ -82,8 +82,16 @@ export default async function StatisticsPage({ )}
-
- +
+ {/* Question History - Recent answers */} +
+ +
+ + {/* Other stats components */} +
+ +
); 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..62ba5f4c4 --- /dev/null +++ b/src/components/app/statistics/question-history.stories.tsx @@ -0,0 +1,15 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import QuestionHistory from './question-history'; + +const meta = { + component: QuestionHistory, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/src/components/app/statistics/question-history.tsx b/src/components/app/statistics/question-history.tsx index e0116bdf8..cb7e7ff08 100644 --- a/src/components/app/statistics/question-history.tsx +++ b/src/components/app/statistics/question-history.tsx @@ -1,13 +1,109 @@ +import { prisma } from '@/lib/prisma'; import { cn } from '@/lib/utils'; +import { CheckCircle, XCircle } from 'lucide-react'; +import { formatDistanceToNow } from 'date-fns'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import Link from 'next/link'; +import { getUser } from '@/actions/user/authed/get-user'; interface QuestionHistoryProps { className?: string; } -export default function QuestionHistory({ className }: QuestionHistoryProps) { +// Format time taken in seconds to a readable format +const formatTimeTaken = (seconds: number | null | undefined) => { + if (!seconds) return 'N/A'; + + if (seconds < 60) { + return `${seconds}s`; + } + + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + + return `${minutes}m ${remainingSeconds}s`; +}; + +export default async function QuestionHistory({ className }: QuestionHistoryProps) { + const user = await getUser(); + + // Initialize with empty array as default + let recentAnswers: any[] = []; + + if (user) { + // Fetch the 10 most recent answers + recentAnswers = await prisma.answers.findMany({ + where: { + userUid: user.uid, + }, + orderBy: { + createdAt: 'desc', + }, + take: 10, + include: { + question: { + include: { + tags: { + include: { + tag: true, + }, + }, + }, + }, + }, + }); + } + return ( -
-

Question History

-
+ + + Recent Questions + + + {recentAnswers.length === 0 ? ( +
+

No recent questions found.

+

+ + Start answering questions + +

+
+ ) : ( +
    + {recentAnswers.map((answer) => ( +
  • + +
    + {answer.correctAnswer ? ( + + ) : ( + + )} +
    +

    + {answer.question.title || answer.question.question.substring(0, 50)} +

    +
    + + {answer.question.difficulty} + + + {formatDistanceToNow(new Date(answer.createdAt), { + addSuffix: true, + })} + + {answer.timeTaken && • {formatTimeTaken(answer.timeTaken)}} +
    +
    +
    + +
  • + ))} +
+ )} +
+
); } From d188bc4c2deb2a27bb94145dd9f4bf3351825b4a Mon Sep 17 00:00:00 2001 From: Logan Ford <110533855+Logannford@users.noreply.github.com> Date: Tue, 25 Mar 2025 22:56:09 +0000 Subject: [PATCH 09/28] feat: correctly rendering rsc's in storybook --- .storybook/README.md | 123 +----------------- .storybook/ServerComponentStory.tsx | 48 ------- .storybook/main.ts | 3 + .storybook/storybook-components/index.tsx | 103 --------------- .../(default_layout)/statistics/page.tsx | 5 - .../statistics/question-history.stories.tsx | 13 +- .../app/statistics/question-history.tsx | 32 +---- 7 files changed, 24 insertions(+), 303 deletions(-) delete mode 100644 .storybook/ServerComponentStory.tsx delete mode 100644 .storybook/storybook-components/index.tsx diff --git a/.storybook/README.md b/.storybook/README.md index 4e194cae7..6d478157b 100644 --- a/.storybook/README.md +++ b/.storybook/README.md @@ -12,123 +12,8 @@ Storybook does not natively support React Server Components (RSC). When trying t ## Our Solution -We've implemented a pattern that allows us to create stories for server components without modifying the actual components: +We use a simple pattern to render server components in Storybook: -1. Create client-side story wrappers that mimic the structure and functionality of server components -2. Use mock data instead of actual data fetching -3. Leverage helper functions to reduce boilerplate code - -## How to Use - -### 1. Import the Helper Functions - -```tsx -import { createServerComponentStory } from '../../../.storybook/ServerComponentStory'; -import { StoryDecorator } from '../../../.storybook/storybook-components'; -``` - -### 2. Create a Client-Side Story Component - -Create a component that mimics your server component's structure: - -```tsx -function MyServerComponentStory(props) { - // Define mock data here - const mockData = {...}; - - return ( - - {/* Mimic your server component's UI here */} - - - ); -} -``` - -### 3. Configure the Story - -Use the `createServerComponentStory` helper function: - -```tsx -const meta = createServerComponentStory(MyServerComponentStory, { - title: 'App/YourCategory/YourComponent', -}) satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - args: { - // Your story args here - }, -}; -``` - -## Example - -Here's an example for a `QuestionHistory` component: - -```tsx -// question-history.stories.tsx -import { createServerComponentStory } from '../../../../.storybook/ServerComponentStory'; -import { StoryDecorator } from '../../../../.storybook/storybook-components'; - -function QuestionHistoryStory({ hasAnswers = true }) { - // Mock data that resembles what the server component would fetch - const recentAnswers = hasAnswers ? [...mockAnswers] : []; - - return ( - - - {/* UI that matches our server component */} - - Recent Questions - - {/* Component content */} - - - ); -} - -const meta = createServerComponentStory(QuestionHistoryStory, { - title: 'App/Statistics/QuestionHistory', -}); - -export default meta; - -export const Default: Story = { - args: { hasAnswers: true }, -}; - -export const Empty: Story = { - args: { hasAnswers: false }, -}; -``` - -## Mock Authentication and Data - -For components that require authentication or data fetching, use the helpers in `storybook-components/index.tsx`: - -```tsx -import { - StoryDecorator, - MockPrisma, - DefaultMockUser, -} from '../../../.storybook/storybook-components'; - -function AuthenticatedComponentStory() { - return ( - - - - ); -} -``` - -## Advantages of This Approach - -1. Server components remain untouched and can use server-side features -2. Stories are isolated and don't depend on actual backend functionality -3. Consistent styling and behavior across stories -4. Reduced boilerplate code +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/ServerComponentStory.tsx b/.storybook/ServerComponentStory.tsx deleted file mode 100644 index cf945aee0..000000000 --- a/.storybook/ServerComponentStory.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react'; - -/** - * ServerComponentStory is a helper function for creating Storybook stories - * for React Server Components. It allows you to create a client-side wrapper - * around a server component's structure for use in Storybook. - * - * @param Component - The client component that will mimic the server component's structure - * @param options - Options for configuring the story - * @returns A configured Meta object for Storybook - */ -export function createServerComponentStory>( - Component: T, - options: { - title: string; - parameters?: Record; - decorators?: Array<(Story: any) => JSX.Element>; - tags?: string[]; - } -) { - return { - title: options.title, - component: Component, - parameters: { - layout: 'centered', - backgrounds: { - default: 'dark', - values: [{ name: 'dark', value: '#090909' }], - }, - ...options.parameters, - }, - decorators: options.decorators || [ - (Story: any) => ( -
- -
- ), - ], - tags: options.tags || ['autodocs'], - }; -} 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/.storybook/storybook-components/index.tsx b/.storybook/storybook-components/index.tsx deleted file mode 100644 index 4088e2210..000000000 --- a/.storybook/storybook-components/index.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import React from 'react'; - -/** - * MockUser represents a user for authentication in stories - */ -export interface MockUser { - uid: string; - email: string; - name: string; - image?: string; - role?: string; -} - -/** - * AuthContext provides a mock authentication context for stories - */ -export const AuthContext = React.createContext<{ - user: MockUser | null; - isLoading: boolean; -}>({ - user: null, - isLoading: false, -}); - -/** - * AuthProvider wraps components with a mock authentication context - */ -export function AuthProvider({ - children, - user = null, - isLoading = false, -}: { - children: React.ReactNode; - user?: MockUser | null; - isLoading?: boolean; -}) { - return {children}; -} - -/** - * useAuth hook for accessing the mock authentication context - */ -export function useAuth() { - return React.useContext(AuthContext); -} - -/** - * MockPrisma provides mock database methods for stories - */ -export const MockPrisma = { - // Add methods as needed for different stories - answers: { - findMany: async (options: any) => { - // Return mock data based on options - return []; - }, - count: async (options: any) => { - return 0; - }, - }, - questions: { - findMany: async (options: any) => { - return []; - }, - count: async (options: any) => { - return 0; - }, - }, - users: { - findUnique: async (options: any) => { - return null; - }, - }, -}; - -/** - * DefaultMockUser can be used as a standard user in stories - */ -export const DefaultMockUser: MockUser = { - uid: 'user-1', - email: 'user@example.com', - name: 'Test User', - image: 'https://via.placeholder.com/40', - role: 'user', -}; - -/** - * StoryDecorator for wrapping stories with common providers - */ -export function StoryDecorator({ - children, - user = DefaultMockUser, - withAuth = true, -}: { - children: React.ReactNode; - user?: MockUser | null; - withAuth?: boolean; -}) { - if (withAuth) { - return {children}; - } - return <>{children}; -} diff --git a/src/app/(app)/(default_layout)/statistics/page.tsx b/src/app/(app)/(default_layout)/statistics/page.tsx index 49644e373..1554c54b8 100644 --- a/src/app/(app)/(default_layout)/statistics/page.tsx +++ b/src/app/(app)/(default_layout)/statistics/page.tsx @@ -87,11 +87,6 @@ export default async function StatisticsPage({
- - {/* Other stats components */} -
- -
); diff --git a/src/components/app/statistics/question-history.stories.tsx b/src/components/app/statistics/question-history.stories.tsx index 62ba5f4c4..e65623be5 100644 --- a/src/components/app/statistics/question-history.stories.tsx +++ b/src/components/app/statistics/question-history.stories.tsx @@ -1,10 +1,19 @@ import type { Meta, StoryObj } from '@storybook/react'; import QuestionHistory from './question-history'; +import { Suspense } from 'react'; + +function QuestionHistoryStory() { + return ( + + + + ); +} const meta = { - component: QuestionHistory, -} satisfies Meta; + component: QuestionHistoryStory, +} satisfies Meta; export default meta; diff --git a/src/components/app/statistics/question-history.tsx b/src/components/app/statistics/question-history.tsx index cb7e7ff08..379551323 100644 --- a/src/components/app/statistics/question-history.tsx +++ b/src/components/app/statistics/question-history.tsx @@ -11,20 +11,6 @@ interface QuestionHistoryProps { className?: string; } -// Format time taken in seconds to a readable format -const formatTimeTaken = (seconds: number | null | undefined) => { - if (!seconds) return 'N/A'; - - if (seconds < 60) { - return `${seconds}s`; - } - - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - - return `${minutes}m ${remainingSeconds}s`; -}; - export default async function QuestionHistory({ className }: QuestionHistoryProps) { const user = await getUser(); @@ -81,21 +67,15 @@ export default async function QuestionHistory({ className }: QuestionHistoryProp ) : ( )} -
+

{answer.question.title || answer.question.question.substring(0, 50)}

-
- - {answer.question.difficulty} - - - {formatDistanceToNow(new Date(answer.createdAt), { - addSuffix: true, - })} - - {answer.timeTaken && • {formatTimeTaken(answer.timeTaken)}} -
+ + {formatDistanceToNow(new Date(answer.createdAt), { + addSuffix: true, + })} +
From 15f3171c84c34f9336c5ccd389dc477952d4e8fb Mon Sep 17 00:00:00 2001 From: Logan Ford <110533855+Logannford@users.noreply.github.com> Date: Tue, 25 Mar 2025 23:06:27 +0000 Subject: [PATCH 10/28] feat: function abstraction, stories created. --- .../(default_layout)/statistics/page.tsx | 7 +- .../statistics/question-history.stories.tsx | 432 +++++++++++++++++- .../app/statistics/question-history.tsx | 46 +- src/utils/data/answers/get-answer.ts | 2 +- src/utils/data/answers/get-user-answer.ts | 41 ++ 5 files changed, 485 insertions(+), 43 deletions(-) diff --git a/src/app/(app)/(default_layout)/statistics/page.tsx b/src/app/(app)/(default_layout)/statistics/page.tsx index 1554c54b8..072d04bef 100644 --- a/src/app/(app)/(default_layout)/statistics/page.tsx +++ b/src/app/(app)/(default_layout)/statistics/page.tsx @@ -22,6 +22,7 @@ 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({ @@ -53,7 +54,7 @@ export default async function StatisticsPage({ const { step } = STATISTICS[range]; // Prefetch data - get both time-grouped and overall stats - const [timeGroupedStats, overallStats] = await Promise.all([ + const [timeGroupedStats, overallStats, recentAnswers] = await Promise.all([ getData({ userUid: user.uid, from: range, @@ -67,6 +68,7 @@ export default async function StatisticsPage({ to: new Date().toISOString(), includeDifficultyData: true, }), + getRecentUserAnswers({ take: 10 }), ]); return ( @@ -83,9 +85,10 @@ export default async function StatisticsPage({ )}
+ {JSON.stringify(recentAnswers)} {/* Question History - Recent answers */}
- +
diff --git a/src/components/app/statistics/question-history.stories.tsx b/src/components/app/statistics/question-history.stories.tsx index e65623be5..f7d1e3aeb 100644 --- a/src/components/app/statistics/question-history.stories.tsx +++ b/src/components/app/statistics/question-history.stories.tsx @@ -3,10 +3,10 @@ import type { Meta, StoryObj } from '@storybook/react'; import QuestionHistory from './question-history'; import { Suspense } from 'react'; -function QuestionHistoryStory() { +function QuestionHistoryStory({ recentAnswers }: { recentAnswers: any[] }) { return ( - + ); } @@ -20,5 +20,431 @@ export default meta; type Story = StoryObj; export const Default: Story = { - args: {}, + 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: [], + }, + }, + { + uid: 'cm8lq00k400044epgkrcuwi3i', + createdAt: '2025-03-23T14:15:29.236Z', + timeTaken: 0, + userAnswerUid: null, + correctAnswer: true, + userUid: '3a57d7e8-8b80-483b-93d0-70fe1f06b0c0', + questionUid: 'fj9348dhj2839h8d7wj9c0-dwn89nb2ubuc8w', + updatedAt: '2025-03-23T14:15:29.236Z', + 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: 'cm8kowo6700034epgq9mvxmej', + createdAt: '2025-03-22T20:57:07.423Z', + timeTaken: 175, + userAnswerUid: '5pgm8kfznb5', + correctAnswer: true, + userUid: '3a57d7e8-8b80-483b-93d0-70fe1f06b0c0', + questionUid: '5pgm8kfznb7', + updatedAt: '2025-03-22T20:57:07.423Z', + questionDate: '', + difficulty: 'EASY', + question: { + uid: '5pgm8kfznb7', + question: "What does the HTTP status code '200' mean?", + createdAt: '2025-03-22T16:47:29.727Z', + updatedAt: '2025-03-23T20:17:51.904Z', + questionDate: '', + answerResource: null, + correctAnswer: '5pgm8kfznb5', + codeSnippet: null, + hint: null, + dailyQuestion: false, + difficulty: 'BEGINNER', + customQuestion: false, + slug: 'what-does-http-200-mean', + slugGenerated: true, + description: null, + title: "What does the HTTP status code '200' mean?", + questionType: 'SIMPLE_MULTIPLE_CHOICE', + expectedParams: null, + functionName: null, + returnType: null, + testCases: null, + nextQuestionSlug: null, + previousQuestionSlug: null, + isPremiumQuestion: false, + afterQuestionInfo: + "

\nTypically, HTTP codes that begin with '2' indicate that the request was successfully received, understood, and accepted.\n

\n
\n

\nThe HTTP status code '200' means the request was successful, indicating that the server returned the requested resource.\n

\n
\n\nView the HTTP cat for '200' here!\n", + tags: [ + { + questionId: '5pgm8kfznb7', + tagId: '136bm5n0g45g', + tag: { uid: '136bm5n0g45g', name: 'http' }, + }, + { + questionId: '5pgm8kfznb7', + tagId: '5pgm8kfznbb', + tag: { uid: '5pgm8kfznbb', name: 'http-codes' }, + }, + { + questionId: '5pgm8kfznb7', + tagId: '5pgm8kfznbd', + tag: { uid: '5pgm8kfznbd', name: 'status-codes' }, + }, + ], + }, + }, + { + uid: 'cm8kj11o100004epg0rxcfjxv', + createdAt: '2025-03-22T18:12:33.842Z', + timeTaken: 0, + userAnswerUid: null, + correctAnswer: true, + userUid: '3a57d7e8-8b80-483b-93d0-70fe1f06b0c0', + questionUid: 'fj9348dhj2839h8d7wj9c0-dwn89nb2ubuc8w', + updatedAt: '2025-03-22T18:12:33.842Z', + 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: 'cm8kj11o200014epg8u3yrhvk', + createdAt: '2025-03-22T18:12:33.842Z', + timeTaken: 0, + userAnswerUid: null, + correctAnswer: false, + userUid: '3a57d7e8-8b80-483b-93d0-70fe1f06b0c0', + questionUid: 'dh3982dh893ucb98nc90-cnuw2-xchmovkd', + updatedAt: '2025-03-22T18:12:33.842Z', + 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: 'cm8kj11o200024epgs9k48d2x', + createdAt: '2025-03-22T18:12:33.842Z', + timeTaken: 0, + userAnswerUid: null, + correctAnswer: true, + userUid: '3a57d7e8-8b80-483b-93d0-70fe1f06b0c0', + questionUid: 'dn3u29x98n23n9cm0c3-d32nuixwenciub', + updatedAt: '2025-03-22T18:12:33.842Z', + 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 index 379551323..0977156dd 100644 --- a/src/components/app/statistics/question-history.tsx +++ b/src/components/app/statistics/question-history.tsx @@ -6,41 +6,15 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import Link from 'next/link'; import { getUser } from '@/actions/user/authed/get-user'; +import { Button } from '@/components/ui/button'; +import type { RecentUserAnswer } from '@/utils/data/answers/get-user-answer'; interface QuestionHistoryProps { className?: string; + recentAnswers: RecentUserAnswer[]; } -export default async function QuestionHistory({ className }: QuestionHistoryProps) { - const user = await getUser(); - - // Initialize with empty array as default - let recentAnswers: any[] = []; - - if (user) { - // Fetch the 10 most recent answers - recentAnswers = await prisma.answers.findMany({ - where: { - userUid: user.uid, - }, - orderBy: { - createdAt: 'desc', - }, - take: 10, - include: { - question: { - include: { - tags: { - include: { - tag: true, - }, - }, - }, - }, - }, - }); - } - +export default async function QuestionHistory({ className, recentAnswers }: QuestionHistoryProps) { return ( @@ -48,12 +22,10 @@ export default async function QuestionHistory({ className }: QuestionHistoryProp {recentAnswers.length === 0 ? ( -
+

No recent questions found.

- - Start answering questions - +

) : ( @@ -63,13 +35,13 @@ export default async function QuestionHistory({ className }: QuestionHistoryProp
{answer.correctAnswer ? ( - + ) : ( - + )}

- {answer.question.title || answer.question.question.substring(0, 50)} + {answer.question.title || answer?.question?.question?.substring(0, 50)}

{formatDistanceToNow(new Date(answer.createdAt), { diff --git a/src/utils/data/answers/get-answer.ts b/src/utils/data/answers/get-answer.ts index de3acf61d..61eb3114b 100644 --- a/src/utils/data/answers/get-answer.ts +++ b/src/utils/data/answers/get-answer.ts @@ -6,7 +6,7 @@ import { prisma } from '@/lib/prisma'; * @param opts * @returns */ -export const getAnswer = async (opts: { questionUid: string; userUid: string }) => { +export const getAnswer = async (opts: { questionUid: string }) => { const { questionUid } = opts; try { 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, + }, + }); +}; From b22308d5eaa8d3b1a8866557ca45f3f93caec0dc Mon Sep 17 00:00:00 2001 From: Logan Ford <110533855+Logannford@users.noreply.github.com> Date: Tue, 25 Mar 2025 23:16:08 +0000 Subject: [PATCH 11/28] feat: messing around with different styling for question history block --- .../(default_layout)/statistics/page.tsx | 1 - .../app/statistics/question-history.tsx | 25 ++++++++++----- src/components/ui/icons/s-pulse-2.tsx | 31 +++++++++++++++++++ 3 files changed, 49 insertions(+), 8 deletions(-) create mode 100644 src/components/ui/icons/s-pulse-2.tsx diff --git a/src/app/(app)/(default_layout)/statistics/page.tsx b/src/app/(app)/(default_layout)/statistics/page.tsx index 072d04bef..7c53f13e6 100644 --- a/src/app/(app)/(default_layout)/statistics/page.tsx +++ b/src/app/(app)/(default_layout)/statistics/page.tsx @@ -85,7 +85,6 @@ export default async function StatisticsPage({ )}
- {JSON.stringify(recentAnswers)} {/* Question History - Recent answers */}
diff --git a/src/components/app/statistics/question-history.tsx b/src/components/app/statistics/question-history.tsx index 0977156dd..85417e846 100644 --- a/src/components/app/statistics/question-history.tsx +++ b/src/components/app/statistics/question-history.tsx @@ -2,12 +2,13 @@ import { prisma } from '@/lib/prisma'; import { cn } from '@/lib/utils'; import { CheckCircle, XCircle } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import Link from 'next/link'; import { getUser } from '@/actions/user/authed/get-user'; import { Button } from '@/components/ui/button'; import type { RecentUserAnswer } from '@/utils/data/answers/get-user-answer'; +import SPulse2 from '@/components/ui/icons/s-pulse-2'; interface QuestionHistoryProps { className?: string; @@ -16,10 +17,17 @@ interface QuestionHistoryProps { export default async function QuestionHistory({ className, recentAnswers }: QuestionHistoryProps) { return ( - - - Recent Questions + + + + + Last 10 questions +

Question History

+
+ + In the last 7 days, you have answered [...] questions. Good job! + {recentAnswers.length === 0 ? (
@@ -32,8 +40,11 @@ export default async function QuestionHistory({ className, recentAnswers }: Ques
    {recentAnswers.map((answer) => (
  • - -
    + +
    {answer.correctAnswer ? ( ) : ( @@ -43,7 +54,7 @@ export default async function QuestionHistory({ className, recentAnswers }: Ques

    {answer.question.title || answer?.question?.question?.substring(0, 50)}

    - + {formatDistanceToNow(new Date(answer.createdAt), { addSuffix: true, })} 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 From 9413370155c1427dd41d4f6eba9af85065736ad0 Mon Sep 17 00:00:00 2001 From: Logan Ford <110533855+Logannford@users.noreply.github.com> Date: Tue, 25 Mar 2025 23:20:03 +0000 Subject: [PATCH 12/28] minor work --- .../app/statistics/question-history.stories.tsx | 2 +- src/components/app/statistics/question-history.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/app/statistics/question-history.stories.tsx b/src/components/app/statistics/question-history.stories.tsx index f7d1e3aeb..4cea4eb3f 100644 --- a/src/components/app/statistics/question-history.stories.tsx +++ b/src/components/app/statistics/question-history.stories.tsx @@ -6,7 +6,7 @@ import { Suspense } from 'react'; function QuestionHistoryStory({ recentAnswers }: { recentAnswers: any[] }) { return ( - + ); } diff --git a/src/components/app/statistics/question-history.tsx b/src/components/app/statistics/question-history.tsx index 85417e846..70d3a91f4 100644 --- a/src/components/app/statistics/question-history.tsx +++ b/src/components/app/statistics/question-history.tsx @@ -19,13 +19,13 @@ export default async function QuestionHistory({ className, recentAnswers }: Ques return ( - + Last 10 questions

    Question History

    - + In the last 7 days, you have answered [...] questions. Good job! @@ -51,10 +51,10 @@ export default async function QuestionHistory({ className, recentAnswers }: Ques )}
    -

    +

    {answer.question.title || answer?.question?.question?.substring(0, 50)}

    - + {formatDistanceToNow(new Date(answer.createdAt), { addSuffix: true, })} From 917a3aac42b5d3f0f040cd59e581a571a6a4437f Mon Sep 17 00:00:00 2001 From: Logan Ford <110533855+Logannford@users.noreply.github.com> Date: Wed, 26 Mar 2025 11:32:05 +0000 Subject: [PATCH 13/28] feat: styling work with question history component --- .../statistics/question-history.stories.tsx | 4 +- .../app/statistics/question-history.tsx | 88 +++++++++++-------- 2 files changed, 55 insertions(+), 37 deletions(-) diff --git a/src/components/app/statistics/question-history.stories.tsx b/src/components/app/statistics/question-history.stories.tsx index 4cea4eb3f..0e91bb920 100644 --- a/src/components/app/statistics/question-history.stories.tsx +++ b/src/components/app/statistics/question-history.stories.tsx @@ -6,7 +6,9 @@ import { Suspense } from 'react'; function QuestionHistoryStory({ recentAnswers }: { recentAnswers: any[] }) { return ( - +
    + +
    ); } diff --git a/src/components/app/statistics/question-history.tsx b/src/components/app/statistics/question-history.tsx index 70d3a91f4..4d225a0b0 100644 --- a/src/components/app/statistics/question-history.tsx +++ b/src/components/app/statistics/question-history.tsx @@ -1,14 +1,12 @@ -import { prisma } from '@/lib/prisma'; -import { cn } from '@/lib/utils'; -import { CheckCircle, XCircle } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; +import { CheckCircle, XCircle } from 'lucide-react'; import Link from 'next/link'; -import { getUser } from '@/actions/user/authed/get-user'; + import { Button } from '@/components/ui/button'; -import type { RecentUserAnswer } from '@/utils/data/answers/get-user-answer'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import SPulse2 from '@/components/ui/icons/s-pulse-2'; +import { cn } from '@/lib/utils'; +import type { RecentUserAnswer } from '@/utils/data/answers/get-user-answer'; interface QuestionHistoryProps { className?: string; @@ -16,45 +14,63 @@ interface QuestionHistoryProps { } export default async function QuestionHistory({ className, recentAnswers }: QuestionHistoryProps) { + const answeredCount = recentAnswers.length; + const correctCount = recentAnswers.filter((answer) => answer.correctAnswer).length; + return ( - - - - - Last 10 questions -

    Question History

    -
    + + +
    + +
    +
    + + Last 10 questions +

    Question History

    +
    +
    - - In the last 7 days, you have answered [...] questions. Good job! + + + 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.

    -

    - -

    +
    +

    No recent questions found.

    +
    ) : (
      {recentAnswers.map((answer) => ( -
    • - -
      - {answer.correctAnswer ? ( - - ) : ( - - )} -
      -

      +
    • + +
      +
      + {answer.correctAnswer ? ( + + ) : ( + + )} +
      +
      +

      {answer.question.title || answer?.question?.question?.substring(0, 50)}

      - + {formatDistanceToNow(new Date(answer.createdAt), { addSuffix: true, })} From 8b433dae58783a54f07d291c18fe4a1e4deffa6e Mon Sep 17 00:00:00 2001 From: Logan Ford <110533855+Logannford@users.noreply.github.com> Date: Wed, 26 Mar 2025 17:45:45 +0000 Subject: [PATCH 14/28] progress with question history block --- .../app/statistics/question-history.tsx | 184 +++++++++++++++--- 1 file changed, 153 insertions(+), 31 deletions(-) diff --git a/src/components/app/statistics/question-history.tsx b/src/components/app/statistics/question-history.tsx index 4d225a0b0..fe04d497e 100644 --- a/src/components/app/statistics/question-history.tsx +++ b/src/components/app/statistics/question-history.tsx @@ -1,19 +1,165 @@ +'use client'; + import { formatDistanceToNow } from 'date-fns'; -import { CheckCircle, XCircle } from 'lucide-react'; +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'; +// 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: RecentUserAnswer[]; + 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`; +}; + +// Client component for detail dropdown +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'} + +
      +
      +
      +
      +
      + ); +} + +// Client component for the question item +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 async function QuestionHistory({ className, recentAnswers }: QuestionHistoryProps) { +export default function QuestionHistory({ + className, + recentAnswers, + dropdownPosition = 'right', +}: QuestionHistoryProps) { const answeredCount = recentAnswers.length; const correctCount = recentAnswers.filter((answer) => answer.correctAnswer).length; @@ -29,6 +175,9 @@ export default async function QuestionHistory({ className, recentAnswers }: Ques

      Question History

      + @@ -51,34 +200,7 @@ export default async function QuestionHistory({ className, recentAnswers }: Ques ) : (
        {recentAnswers.map((answer) => ( -
      • - -
        -
        - {answer.correctAnswer ? ( - - ) : ( - - )} -
        -
        -

        - {answer.question.title || answer?.question?.question?.substring(0, 50)} -

        - - {formatDistanceToNow(new Date(answer.createdAt), { - addSuffix: true, - })} - -
        -
        - -
      • + ))}
      )} From 7ca391055a4c0d15fd5837223e7d96f64fee6a8d Mon Sep 17 00:00:00 2001 From: Logan Ford <110533855+Logannford@users.noreply.github.com> Date: Wed, 26 Mar 2025 18:21:18 +0000 Subject: [PATCH 15/28] feat: adds new DifficultyRadialChart to homepage --- .../homepage/features/features-bento-grid.tsx | 2 +- .../homepage/features/progression-box.tsx | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) 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 */} -
      - +
      +
      ); From c09a40e732cea8907dc88dc8469b9e1613deb216 Mon Sep 17 00:00:00 2001 From: Logan Ford <110533855+Logannford@users.noreply.github.com> Date: Wed, 26 Mar 2025 18:23:43 +0000 Subject: [PATCH 16/28] minor changes to story and revert to rsc --- .../statistics/question-history.stories.tsx | 217 ------------------ .../app/statistics/question-history.tsx | 4 - 2 files changed, 221 deletions(-) diff --git a/src/components/app/statistics/question-history.stories.tsx b/src/components/app/statistics/question-history.stories.tsx index 0e91bb920..1192283f6 100644 --- a/src/components/app/statistics/question-history.stories.tsx +++ b/src/components/app/statistics/question-history.stories.tsx @@ -224,223 +224,6 @@ export const Default: Story = { tags: [], }, }, - { - uid: 'cm8lq00k400044epgkrcuwi3i', - createdAt: '2025-03-23T14:15:29.236Z', - timeTaken: 0, - userAnswerUid: null, - correctAnswer: true, - userUid: '3a57d7e8-8b80-483b-93d0-70fe1f06b0c0', - questionUid: 'fj9348dhj2839h8d7wj9c0-dwn89nb2ubuc8w', - updatedAt: '2025-03-23T14:15:29.236Z', - 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: 'cm8kowo6700034epgq9mvxmej', - createdAt: '2025-03-22T20:57:07.423Z', - timeTaken: 175, - userAnswerUid: '5pgm8kfznb5', - correctAnswer: true, - userUid: '3a57d7e8-8b80-483b-93d0-70fe1f06b0c0', - questionUid: '5pgm8kfznb7', - updatedAt: '2025-03-22T20:57:07.423Z', - questionDate: '', - difficulty: 'EASY', - question: { - uid: '5pgm8kfznb7', - question: "What does the HTTP status code '200' mean?", - createdAt: '2025-03-22T16:47:29.727Z', - updatedAt: '2025-03-23T20:17:51.904Z', - questionDate: '', - answerResource: null, - correctAnswer: '5pgm8kfznb5', - codeSnippet: null, - hint: null, - dailyQuestion: false, - difficulty: 'BEGINNER', - customQuestion: false, - slug: 'what-does-http-200-mean', - slugGenerated: true, - description: null, - title: "What does the HTTP status code '200' mean?", - questionType: 'SIMPLE_MULTIPLE_CHOICE', - expectedParams: null, - functionName: null, - returnType: null, - testCases: null, - nextQuestionSlug: null, - previousQuestionSlug: null, - isPremiumQuestion: false, - afterQuestionInfo: - "

      \nTypically, HTTP codes that begin with '2' indicate that the request was successfully received, understood, and accepted.\n

      \n
      \n

      \nThe HTTP status code '200' means the request was successful, indicating that the server returned the requested resource.\n

      \n
      \n\nView the HTTP cat for '200' here!\n", - tags: [ - { - questionId: '5pgm8kfznb7', - tagId: '136bm5n0g45g', - tag: { uid: '136bm5n0g45g', name: 'http' }, - }, - { - questionId: '5pgm8kfznb7', - tagId: '5pgm8kfznbb', - tag: { uid: '5pgm8kfznbb', name: 'http-codes' }, - }, - { - questionId: '5pgm8kfznb7', - tagId: '5pgm8kfznbd', - tag: { uid: '5pgm8kfznbd', name: 'status-codes' }, - }, - ], - }, - }, - { - uid: 'cm8kj11o100004epg0rxcfjxv', - createdAt: '2025-03-22T18:12:33.842Z', - timeTaken: 0, - userAnswerUid: null, - correctAnswer: true, - userUid: '3a57d7e8-8b80-483b-93d0-70fe1f06b0c0', - questionUid: 'fj9348dhj2839h8d7wj9c0-dwn89nb2ubuc8w', - updatedAt: '2025-03-22T18:12:33.842Z', - 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: 'cm8kj11o200014epg8u3yrhvk', - createdAt: '2025-03-22T18:12:33.842Z', - timeTaken: 0, - userAnswerUid: null, - correctAnswer: false, - userUid: '3a57d7e8-8b80-483b-93d0-70fe1f06b0c0', - questionUid: 'dh3982dh893ucb98nc90-cnuw2-xchmovkd', - updatedAt: '2025-03-22T18:12:33.842Z', - 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: 'cm8kj11o200024epgs9k48d2x', - createdAt: '2025-03-22T18:12:33.842Z', - timeTaken: 0, - userAnswerUid: null, - correctAnswer: true, - userUid: '3a57d7e8-8b80-483b-93d0-70fe1f06b0c0', - questionUid: 'dn3u29x98n23n9cm0c3-d32nuixwenciub', - updatedAt: '2025-03-22T18:12:33.842Z', - 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: [], - }, - }, ], }, }; diff --git a/src/components/app/statistics/question-history.tsx b/src/components/app/statistics/question-history.tsx index fe04d497e..b63b836c1 100644 --- a/src/components/app/statistics/question-history.tsx +++ b/src/components/app/statistics/question-history.tsx @@ -1,5 +1,3 @@ -'use client'; - import { formatDistanceToNow } from 'date-fns'; import { CheckCircle, Ellipsis, XCircle, Clock, Calendar, BarChart3, Info } from 'lucide-react'; import Link from 'next/link'; @@ -47,7 +45,6 @@ const formatTimeTaken = (seconds: number | null | undefined) => { return `${minutes}m ${remainingSeconds}s`; }; -// Client component for detail dropdown function QuestionDetailDropdown({ answer, position, @@ -109,7 +106,6 @@ function QuestionDetailDropdown({ ); } -// Client component for the question item function QuestionItem({ answer, dropdownPosition, From 17aee2d6f945ca9337e4f0abc95bbac713e25033 Mon Sep 17 00:00:00 2001 From: Logan Ford <110533855+Logannford@users.noreply.github.com> Date: Wed, 26 Mar 2025 21:50:45 +0000 Subject: [PATCH 17/28] chore: adds tremor area chart & new directory for charts --- package.json | 1 + .../roadmaps/[uid]/roadmap-stats-chart.tsx | 2 +- .../statistics/question-tracker.stories.tsx | 19 + .../app/statistics/question-tracker.tsx | 6 +- .../app/statistics/top-tags.stories.tsx | 15 + src/components/app/statistics/top-tags.tsx | 3 + .../app/statistics/total-question-chart.tsx | 2 +- src/components/charts/area-chart.tsx | 916 ++++++++++++++++++ src/components/{ui => charts}/chart.tsx | 0 .../{ui/tremor => charts}/tracker.tsx | 0 .../homepage/features/progression-chart.tsx | 2 +- src/hooks/use-on-window-resize.ts | 15 + src/lib/chart-utils.ts | 120 +++ 13 files changed, 1096 insertions(+), 5 deletions(-) create mode 100644 src/components/app/statistics/question-tracker.stories.tsx create mode 100644 src/components/app/statistics/top-tags.stories.tsx create mode 100644 src/components/app/statistics/top-tags.tsx create mode 100644 src/components/charts/area-chart.tsx rename src/components/{ui => charts}/chart.tsx (100%) rename src/components/{ui/tremor => charts}/tracker.tsx (100%) create mode 100644 src/hooks/use-on-window-resize.ts create mode 100644 src/lib/chart-utils.ts diff --git a/package.json b/package.json index 9a760e230..ed48b2ee7 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/components/app/roadmaps/[uid]/roadmap-stats-chart.tsx b/src/components/app/roadmaps/[uid]/roadmap-stats-chart.tsx index 243f47f8f..67643d6d9 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 { UserRoadmapsWithAnswers } from '@/types/Roadmap'; const chartConfig = { 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..2bd68de11 --- /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 './question-tracker'; + +const meta = { + component: QuestionTracker, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + stats: {}, + step: 'month', + range: {}, + }, +}; diff --git a/src/components/app/statistics/question-tracker.tsx b/src/components/app/statistics/question-tracker.tsx index 95fb729a0..450350e20 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/tremor/tracker'; +import { Tracker } from '@/components/charts/tracker'; import { cn } from '@/lib/utils'; import { StatsSteps } from '@/types/Stats'; @@ -59,7 +59,9 @@ export default function QuestionTracker({ stats, step, range, className }: Quest return { color, - tooltip: `${formattedDate}: ${totalQuestions} ${totalQuestions === 1 ? 'question' : 'questions'}`, + tooltip: `${formattedDate}: ${totalQuestions} ${ + totalQuestions === 1 ? 'question' : 'questions' + }`, }; }); 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 index 10883ad08..2f9b01e2e 100644 --- a/src/components/app/statistics/total-question-chart.tsx +++ b/src/components/app/statistics/total-question-chart.tsx @@ -11,7 +11,7 @@ import { ChartContainer, ChartTooltip, ChartTooltipContent, -} from '@/components/ui/chart'; +} from '@/components/charts/chart'; import { Select, SelectContent, 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/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/ui/tremor/tracker.tsx b/src/components/charts/tracker.tsx similarity index 100% rename from src/components/ui/tremor/tracker.tsx rename to src/components/charts/tracker.tsx 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/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..9e4ece45e --- /dev/null +++ b/src/lib/chart-utils.ts @@ -0,0 +1,120 @@ +// 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', + }, +} 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; +} From 515b949e1068faddad7f333688a6330a00fa5cfc Mon Sep 17 00:00:00 2001 From: Logan Ford <110533855+Logannford@users.noreply.github.com> Date: Wed, 26 Mar 2025 22:01:45 +0000 Subject: [PATCH 18/28] feat: adds story for total-question-chart --- .../total-question-chart.stories.tsx | 198 ++++++++++++++++++ .../app/statistics/total-question-chart.tsx | 161 ++++---------- 2 files changed, 233 insertions(+), 126 deletions(-) create mode 100644 src/components/app/statistics/total-question-chart.stories.tsx diff --git a/src/components/app/statistics/total-question-chart.stories.tsx b/src/components/app/statistics/total-question-chart.stories.tsx new file mode 100644 index 000000000..e7830a564 --- /dev/null +++ b/src/components/app/statistics/total-question-chart.stories.tsx @@ -0,0 +1,198 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { StatsChartData } from './total-question-chart'; +import QuestionChart from './total-question-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 mock data with upward trend +const createUpwardTrendData = ( + count: number, + step: 'day' | 'week' | 'month', + startValue: number = 5 +): StatsChartData => { + const dates = generateDates(count, step); + const result: StatsChartData = {}; + + dates.forEach((date, index) => { + // Increasing trend with some randomness + const questions = Math.floor(startValue + index * 3 + Math.random() * 4); + + result[date] = { + totalQuestions: questions, + tagCounts: { + javascript: Math.floor(questions * 0.4), + react: Math.floor(questions * 0.3), + typescript: Math.floor(questions * 0.2), + html: Math.floor(questions * 0.1), + }, + tags: ['javascript', 'react', 'typescript', 'html'], + }; + }); + + return result; +}; + +// Create mock data with downward trend +const createDownwardTrendData = ( + count: number, + step: 'day' | 'week' | 'month', + startValue: number = 40 +): StatsChartData => { + const dates = generateDates(count, step); + const result: StatsChartData = {}; + + dates.forEach((date, index) => { + // Decreasing trend with some randomness + const questions = Math.max(0, Math.floor(startValue - index * 2 + Math.random() * 3)); + + result[date] = { + totalQuestions: questions, + tagCounts: { + javascript: Math.floor(questions * 0.4), + react: Math.floor(questions * 0.3), + typescript: Math.floor(questions * 0.2), + html: Math.floor(questions * 0.1), + }, + tags: ['javascript', 'react', 'typescript', 'html'], + }; + }); + + return result; +}; + +// create mock data with fluctuating trend +const createFluctuatingTrendData = ( + count: number, + step: 'day' | 'week' | 'month', + baseline: number = 20 +): StatsChartData => { + const dates = generateDates(count, step); + const result: StatsChartData = {}; + + dates.forEach((date, index) => { + // Fluctuating pattern with sine wave + const amplitude = 15; + const period = count / 3; + const questions = Math.max( + 0, + Math.floor(baseline + amplitude * Math.sin((index / period) * Math.PI) + Math.random() * 5) + ); + + result[date] = { + totalQuestions: questions, + tagCounts: { + javascript: Math.floor(questions * 0.4), + react: Math.floor(questions * 0.3), + typescript: Math.floor(questions * 0.2), + html: Math.floor(questions * 0.1), + }, + tags: ['javascript', 'react', 'typescript', 'html'], + }; + }); + + return result; +}; + +// Create mock data sets +const dayDataUpward = createUpwardTrendData(14, 'day'); +const weekDataDownward = createDownwardTrendData(10, 'week'); +const monthDataFluctuating = createFluctuatingTrendData(12, 'month'); + +function QuestionChartWrapper({ + questionData, + step, + backgroundColor, +}: { + questionData: StatsChartData; + step: 'day' | 'week' | 'month'; + backgroundColor?: string; +}) { + return ( +
      + +
      + ); +} + +const meta = { + title: 'App/Statistics/QuestionChart', + component: QuestionChartWrapper, + parameters: { + layout: 'centered', + backgrounds: { + default: 'dark', + values: [{ name: 'dark', value: '#090909' }], + }, + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + questionData: dayDataUpward, + step: 'day', + backgroundColor: 'bg-black-100', + }, + parameters: { + docs: { + description: { + story: 'Shows an upward trend in daily questions answered over the past 14 days.', + }, + }, + }, +}; + +export const WeeklyDownwardTrend: Story = { + args: { + questionData: weekDataDownward, + step: 'week', + backgroundColor: 'bg-black-100', + }, + parameters: { + docs: { + description: { + story: 'Shows a downward trend in weekly questions answered over the past 10 weeks.', + }, + }, + }, +}; + +export const MonthlyFluctuating: Story = { + args: { + questionData: monthDataFluctuating, + step: 'month', + backgroundColor: 'bg-black-100', + }, + parameters: { + docs: { + description: { + story: 'Shows a fluctuating pattern in monthly questions answered over the past 12 months.', + }, + }, + }, +}; diff --git a/src/components/app/statistics/total-question-chart.tsx b/src/components/app/statistics/total-question-chart.tsx index 2f9b01e2e..74efa17b9 100644 --- a/src/components/app/statistics/total-question-chart.tsx +++ b/src/components/app/statistics/total-question-chart.tsx @@ -1,32 +1,12 @@ '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 { TrendingUp, TrendingDown, Circle } from 'lucide-react'; import NumberFlow from '@number-flow/react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { - ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from '@/components/charts/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; +import { AreaChart } from '@/components/charts/area-chart'; export interface StatsChartData { [key: string]: { @@ -65,9 +45,11 @@ export default function QuestionChart({ }, [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 orderedChartData = useMemo(() => { + return [...chartData].sort((a, b) => { + return new Date(a.date).getTime() - new Date(b.date).getTime(); + }); + }, [chartData]); const trend = useMemo(() => { // if there is less than 2 periods, return 0 @@ -76,15 +58,15 @@ export default function QuestionChart({ } // get the first and last period of the chart data - const firstPeriod = chartData[0]; - const lastPeriod = chartData[chartData.length - 1]; + 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 treat as 0 * lastPeriod.questions increase + // If starting from 0, treat as 0 * lastPeriod.questions increase return { percentage: 100 * lastPeriod.questions, isNeutral: false, @@ -101,71 +83,11 @@ export default function QuestionChart({ isNeutral: percentageChange === 0, isUp: percentageChange > 0, }; - }, [chartData]); - - const maxQuestions = useMemo(() => { - return Math.max(...chartData.map((data) => data.questions)); - }, [chartData]); + }, [orderedChartData]); - 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' ? ( - - ) : ( - - )} - - ); + // Format value for the chart to show whole numbers + const valueFormatter = (value: number) => { + return value.toFixed(0); }; return ( @@ -186,47 +108,34 @@ export default function QuestionChart({ )}
    -
    - - Last {chartData.length} {step}s + + Last {orderedChartData.length} {step}s
    - - + - {renderChart()} - + data={orderedChartData} + index="date" + categories={['questions']} + colors={['blue']} + valueFormatter={valueFormatter} + showXAxis={true} + showYAxis={true} + showGridLines={true} + yAxisWidth={40} + showLegend={false} + showTooltip={true} + fill={chartType === 'bar' ? 'solid' : 'gradient'} + type={chartType === 'bar' ? 'default' : 'default'} + tickGap={chartType === 'bar' ? 40 : 20} + connectNulls={true} + autoMinValue={chartType === 'bar' ? false : true} + /> ); From cb97c957585914417e42f011ec6e521b1acd245f Mon Sep 17 00:00:00 2001 From: Logan Ford <110533855+Logannford@users.noreply.github.com> Date: Wed, 26 Mar 2025 22:11:25 +0000 Subject: [PATCH 19/28] chore: minor cleanup --- .../(default_layout)/statistics/page.tsx | 2 +- .../statistics/question-tracker.stories.tsx | 4 +- .../total-question-chart.stories.tsx | 4 +- src/components/charts/area-chart.stories.tsx | 243 ++++++++++++++++++ .../charts/total-question-chart.stories.tsx | 198 ++++++++++++++ .../total-question-chart.tsx | 0 src/components/charts/tracker.stories.tsx | 24 ++ .../statistics/stats-report-section.tsx | 2 +- src/utils/index.ts | 2 +- 9 files changed, 472 insertions(+), 7 deletions(-) create mode 100644 src/components/charts/area-chart.stories.tsx create mode 100644 src/components/charts/total-question-chart.stories.tsx rename src/components/{app/statistics => charts}/total-question-chart.tsx (100%) create mode 100644 src/components/charts/tracker.stories.tsx diff --git a/src/app/(app)/(default_layout)/statistics/page.tsx b/src/app/(app)/(default_layout)/statistics/page.tsx index 7c53f13e6..00dcd038d 100644 --- a/src/app/(app)/(default_layout)/statistics/page.tsx +++ b/src/app/(app)/(default_layout)/statistics/page.tsx @@ -1,7 +1,7 @@ import dynamic from 'next/dynamic'; import StatsRangePicker from '@/components/app/statistics/range-picker'; -import QuestionChart from '@/components/app/statistics/total-question-chart'; +import QuestionChart from '@/components/charts/total-question-chart'; import QuestionHistory from '@/components/app/statistics/question-history'; const DifficultyRadialChart = dynamic( diff --git a/src/components/app/statistics/question-tracker.stories.tsx b/src/components/app/statistics/question-tracker.stories.tsx index 2bd68de11..ecd31b31c 100644 --- a/src/components/app/statistics/question-tracker.stories.tsx +++ b/src/components/app/statistics/question-tracker.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react'; -import QuestionTracker from './question-tracker'; +import QuestionTracker from '@/components/app/statistics/question-tracker'; const meta = { component: QuestionTracker, @@ -14,6 +14,6 @@ export const Default: Story = { args: { stats: {}, step: 'month', - range: {}, + range: '7d', }, }; diff --git a/src/components/app/statistics/total-question-chart.stories.tsx b/src/components/app/statistics/total-question-chart.stories.tsx index e7830a564..215a5b530 100644 --- a/src/components/app/statistics/total-question-chart.stories.tsx +++ b/src/components/app/statistics/total-question-chart.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { StatsChartData } from './total-question-chart'; -import QuestionChart from './total-question-chart'; +import { StatsChartData } from '@/components/charts/total-question-chart'; +import QuestionChart from '@/components/charts/total-question-chart'; // Helper function to generate dates for the past n days/weeks/months const generateDates = (count: number, step: 'day' | 'week' | 'month'): string[] => { diff --git a/src/components/charts/area-chart.stories.tsx b/src/components/charts/area-chart.stories.tsx new file mode 100644 index 000000000..c12a95c76 --- /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: ['blue'], + 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/total-question-chart.stories.tsx b/src/components/charts/total-question-chart.stories.tsx new file mode 100644 index 000000000..77ff16c75 --- /dev/null +++ b/src/components/charts/total-question-chart.stories.tsx @@ -0,0 +1,198 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { StatsChartData } from './total-question-chart'; +import QuestionChart from './total-question-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 mock data with upward trend +const createUpwardTrendData = ( + count: number, + step: 'day' | 'week' | 'month', + startValue: number = 5 +): StatsChartData => { + const dates = generateDates(count, step); + const result: StatsChartData = {}; + + dates.forEach((date, index) => { + // Increasing trend with some randomness + const questions = Math.floor(startValue + index * 3 + Math.random() * 4); + + result[date] = { + totalQuestions: questions, + tagCounts: { + javascript: Math.floor(questions * 0.4), + react: Math.floor(questions * 0.3), + typescript: Math.floor(questions * 0.2), + html: Math.floor(questions * 0.1), + }, + tags: ['javascript', 'react', 'typescript', 'html'], + }; + }); + + return result; +}; + +// Create mock data with downward trend +const createDownwardTrendData = ( + count: number, + step: 'day' | 'week' | 'month', + startValue: number = 40 +): StatsChartData => { + const dates = generateDates(count, step); + const result: StatsChartData = {}; + + dates.forEach((date, index) => { + // Decreasing trend with some randomness + const questions = Math.max(0, Math.floor(startValue - index * 2 + Math.random() * 3)); + + result[date] = { + totalQuestions: questions, + tagCounts: { + javascript: Math.floor(questions * 0.4), + react: Math.floor(questions * 0.3), + typescript: Math.floor(questions * 0.2), + html: Math.floor(questions * 0.1), + }, + tags: ['javascript', 'react', 'typescript', 'html'], + }; + }); + + return result; +}; + +// create mock data with fluctuating trend +const createFluctuatingTrendData = ( + count: number, + step: 'day' | 'week' | 'month', + baseline: number = 20 +): StatsChartData => { + const dates = generateDates(count, step); + const result: StatsChartData = {}; + + dates.forEach((date, index) => { + // Fluctuating pattern with sine wave + const amplitude = 15; + const period = count / 3; + const questions = Math.max( + 0, + Math.floor(baseline + amplitude * Math.sin((index / period) * Math.PI) + Math.random() * 5) + ); + + result[date] = { + totalQuestions: questions, + tagCounts: { + javascript: Math.floor(questions * 0.4), + react: Math.floor(questions * 0.3), + typescript: Math.floor(questions * 0.2), + html: Math.floor(questions * 0.1), + }, + tags: ['javascript', 'react', 'typescript', 'html'], + }; + }); + + return result; +}; + +// Create mock data sets +const dayDataUpward = createUpwardTrendData(14, 'day'); +const weekDataDownward = createDownwardTrendData(10, 'week'); +const monthDataFluctuating = createFluctuatingTrendData(12, 'month'); + +function QuestionChartWrapper({ + questionData, + step, + backgroundColor, +}: { + questionData: StatsChartData; + step: 'day' | 'week' | 'month'; + backgroundColor?: string; +}) { + return ( +
    + +
    + ); +} + +const meta = { + title: 'Charts/QuestionChart', + component: QuestionChartWrapper, + parameters: { + layout: 'centered', + backgrounds: { + default: 'dark', + values: [{ name: 'dark', value: '#090909' }], + }, + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + questionData: dayDataUpward, + step: 'day', + backgroundColor: 'bg-black-100', + }, + parameters: { + docs: { + description: { + story: 'Shows an upward trend in daily questions answered over the past 14 days.', + }, + }, + }, +}; + +export const WeeklyDownwardTrend: Story = { + args: { + questionData: weekDataDownward, + step: 'week', + backgroundColor: 'bg-black-100', + }, + parameters: { + docs: { + description: { + story: 'Shows a downward trend in weekly questions answered over the past 10 weeks.', + }, + }, + }, +}; + +export const MonthlyFluctuating: Story = { + args: { + questionData: monthDataFluctuating, + step: 'month', + backgroundColor: 'bg-black-100', + }, + parameters: { + docs: { + description: { + story: 'Shows a fluctuating pattern in monthly questions answered over the past 12 months.', + }, + }, + }, +}; diff --git a/src/components/app/statistics/total-question-chart.tsx b/src/components/charts/total-question-chart.tsx similarity index 100% rename from src/components/app/statistics/total-question-chart.tsx rename to src/components/charts/total-question-chart.tsx 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/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/utils/index.ts b/src/utils/index.ts index 1c177880b..b720bbf32 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 { QuestionDifficulty } from '@/types/Questions'; From a866d8a7f7240e032d097bd5fd7a936f1bbb0085 Mon Sep 17 00:00:00 2001 From: Logan Ford <110533855+Logannford@users.noreply.github.com> Date: Thu, 27 Mar 2025 20:03:04 +0000 Subject: [PATCH 20/28] feat: some story work, adds new color to chart utils & custom tooltip --- src/components/app/navigation/sidebar.tsx | 2 +- .../total-question-chart.stories.tsx | 198 ---- src/components/charts/area-chart.stories.tsx | 2 +- src/components/charts/line-chart.stories.tsx | 144 +++ src/components/charts/line-chart.tsx | 845 ++++++++++++++++++ src/components/charts/tooltip.tsx | 82 ++ .../charts/total-question-chart.stories.tsx | 282 +++--- .../charts/total-question-chart.tsx | 79 +- src/lib/chart-utils.ts | 6 + 9 files changed, 1248 insertions(+), 392 deletions(-) delete mode 100644 src/components/app/statistics/total-question-chart.stories.tsx create mode 100644 src/components/charts/line-chart.stories.tsx create mode 100644 src/components/charts/line-chart.tsx create mode 100644 src/components/charts/tooltip.tsx diff --git a/src/components/app/navigation/sidebar.tsx b/src/components/app/navigation/sidebar.tsx index e5182a52a..e0b7f7c9b 100644 --- a/src/components/app/navigation/sidebar.tsx +++ b/src/components/app/navigation/sidebar.tsx @@ -158,7 +158,7 @@ export function AppSidebar({ user, profile, suggestion }: AppSidebarProps) { url: '/statistics', icon: BChart3, tooltip: 'Statistics', - defaultOpen: false, + defaultOpen: true, subItems: [ { title: 'Overview', diff --git a/src/components/app/statistics/total-question-chart.stories.tsx b/src/components/app/statistics/total-question-chart.stories.tsx deleted file mode 100644 index 215a5b530..000000000 --- a/src/components/app/statistics/total-question-chart.stories.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react'; -import { StatsChartData } from '@/components/charts/total-question-chart'; -import QuestionChart from '@/components/charts/total-question-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 mock data with upward trend -const createUpwardTrendData = ( - count: number, - step: 'day' | 'week' | 'month', - startValue: number = 5 -): StatsChartData => { - const dates = generateDates(count, step); - const result: StatsChartData = {}; - - dates.forEach((date, index) => { - // Increasing trend with some randomness - const questions = Math.floor(startValue + index * 3 + Math.random() * 4); - - result[date] = { - totalQuestions: questions, - tagCounts: { - javascript: Math.floor(questions * 0.4), - react: Math.floor(questions * 0.3), - typescript: Math.floor(questions * 0.2), - html: Math.floor(questions * 0.1), - }, - tags: ['javascript', 'react', 'typescript', 'html'], - }; - }); - - return result; -}; - -// Create mock data with downward trend -const createDownwardTrendData = ( - count: number, - step: 'day' | 'week' | 'month', - startValue: number = 40 -): StatsChartData => { - const dates = generateDates(count, step); - const result: StatsChartData = {}; - - dates.forEach((date, index) => { - // Decreasing trend with some randomness - const questions = Math.max(0, Math.floor(startValue - index * 2 + Math.random() * 3)); - - result[date] = { - totalQuestions: questions, - tagCounts: { - javascript: Math.floor(questions * 0.4), - react: Math.floor(questions * 0.3), - typescript: Math.floor(questions * 0.2), - html: Math.floor(questions * 0.1), - }, - tags: ['javascript', 'react', 'typescript', 'html'], - }; - }); - - return result; -}; - -// create mock data with fluctuating trend -const createFluctuatingTrendData = ( - count: number, - step: 'day' | 'week' | 'month', - baseline: number = 20 -): StatsChartData => { - const dates = generateDates(count, step); - const result: StatsChartData = {}; - - dates.forEach((date, index) => { - // Fluctuating pattern with sine wave - const amplitude = 15; - const period = count / 3; - const questions = Math.max( - 0, - Math.floor(baseline + amplitude * Math.sin((index / period) * Math.PI) + Math.random() * 5) - ); - - result[date] = { - totalQuestions: questions, - tagCounts: { - javascript: Math.floor(questions * 0.4), - react: Math.floor(questions * 0.3), - typescript: Math.floor(questions * 0.2), - html: Math.floor(questions * 0.1), - }, - tags: ['javascript', 'react', 'typescript', 'html'], - }; - }); - - return result; -}; - -// Create mock data sets -const dayDataUpward = createUpwardTrendData(14, 'day'); -const weekDataDownward = createDownwardTrendData(10, 'week'); -const monthDataFluctuating = createFluctuatingTrendData(12, 'month'); - -function QuestionChartWrapper({ - questionData, - step, - backgroundColor, -}: { - questionData: StatsChartData; - step: 'day' | 'week' | 'month'; - backgroundColor?: string; -}) { - return ( -
    - -
    - ); -} - -const meta = { - title: 'App/Statistics/QuestionChart', - component: QuestionChartWrapper, - parameters: { - layout: 'centered', - backgrounds: { - default: 'dark', - values: [{ name: 'dark', value: '#090909' }], - }, - }, - tags: ['autodocs'], -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - args: { - questionData: dayDataUpward, - step: 'day', - backgroundColor: 'bg-black-100', - }, - parameters: { - docs: { - description: { - story: 'Shows an upward trend in daily questions answered over the past 14 days.', - }, - }, - }, -}; - -export const WeeklyDownwardTrend: Story = { - args: { - questionData: weekDataDownward, - step: 'week', - backgroundColor: 'bg-black-100', - }, - parameters: { - docs: { - description: { - story: 'Shows a downward trend in weekly questions answered over the past 10 weeks.', - }, - }, - }, -}; - -export const MonthlyFluctuating: Story = { - args: { - questionData: monthDataFluctuating, - step: 'month', - backgroundColor: 'bg-black-100', - }, - parameters: { - docs: { - description: { - story: 'Shows a fluctuating pattern in monthly questions answered over the past 12 months.', - }, - }, - }, -}; diff --git a/src/components/charts/area-chart.stories.tsx b/src/components/charts/area-chart.stories.tsx index c12a95c76..7bfab558a 100644 --- a/src/components/charts/area-chart.stories.tsx +++ b/src/components/charts/area-chart.stories.tsx @@ -115,7 +115,7 @@ export const DailyUpwardTrend: Story = { data: dailyUpwardData, index: 'date', categories: ['value'], - colors: ['blue'], + colors: ['accent'], valueFormatter, showXAxis: true, showYAxis: true, diff --git a/src/components/charts/line-chart.stories.tsx b/src/components/charts/line-chart.stories.tsx new file mode 100644 index 000000000..902d24525 --- /dev/null +++ b/src/components/charts/line-chart.stories.tsx @@ -0,0 +1,144 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { LineChart } from './line-chart'; +import Tooltip from './tooltip'; + +function LineChartStory({ data }: { data: any[] }) { + return ( +
    + value.toString()} + showXAxis={true} + showYAxis={true} + showGridLines={true} + showLegend={false} + showTooltip={true} + customTooltip={(props) => } + /> +
    + ); +} + +const meta: Meta = { + title: 'Charts/LineChart', + component: LineChartStory, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +// 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(); + +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: ['blue', 'emerald'], + showLegend: true, + }, +}; + +export const NoGrid: Story = { + args: { + ...Default.args, + showGridLines: false, + }, +}; + +export const CustomColors: Story = { + args: { + ...Default.args, + colors: ['cyan', 'pink', 'amber'], + }, +}; diff --git a/src/components/charts/line-chart.tsx b/src/components/charts/line-chart.tsx new file mode 100644 index 000000000..552f74e7d --- /dev/null +++ b/src/components/charts/line-chart.tsx @@ -0,0 +1,845 @@ +// 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'; + +//#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="" + 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/tooltip.tsx b/src/components/charts/tooltip.tsx new file mode 100644 index 000000000..25d3154bc --- /dev/null +++ b/src/components/charts/tooltip.tsx @@ -0,0 +1,82 @@ +import { format } from 'date-fns'; + +// 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; +} + +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 color from available properties + const color = entry.color || entry.stroke || entry.fill || '#3b82f6'; + + return ( +
    +
    +
    +

    {name}

    +
    +

    {displayValue}

    +
    + ); + })} +
    + ); +} diff --git a/src/components/charts/total-question-chart.stories.tsx b/src/components/charts/total-question-chart.stories.tsx index 77ff16c75..f7297df39 100644 --- a/src/components/charts/total-question-chart.stories.tsx +++ b/src/components/charts/total-question-chart.stories.tsx @@ -1,198 +1,162 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { StatsChartData } from './total-question-chart'; 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; -// Helper function to generate dates for the past n days/weeks/months -const generateDates = (count: number, step: 'day' | 'week' | 'month'): string[] => { +// Generate dates for the past week +const generateDates = (days: number) => { const dates: string[] = []; - const now = new Date(); + const today = new Date(); - for (let i = count - 1; i >= 0; i--) { - const date = new Date(); + for (let i = days - 1; i >= 0; i--) { + const date = new Date(today); + date.setDate(today.getDate() - i); - 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 "Month Day, Year" + const formattedDate = date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); - // Format date as "Apr 15" - dates.push(date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })); + dates.push(formattedDate); } return dates; }; -// Create mock data with upward trend -const createUpwardTrendData = ( - count: number, - step: 'day' | 'week' | 'month', - startValue: number = 5 -): StatsChartData => { - const dates = generateDates(count, step); - const result: StatsChartData = {}; - - dates.forEach((date, index) => { - // Increasing trend with some randomness - const questions = Math.floor(startValue + index * 3 + Math.random() * 4); - - result[date] = { - totalQuestions: questions, - tagCounts: { - javascript: Math.floor(questions * 0.4), - react: Math.floor(questions * 0.3), - typescript: Math.floor(questions * 0.2), - html: Math.floor(questions * 0.1), - }, - tags: ['javascript', 'react', 'typescript', 'html'], +// 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), }; - }); - - return result; -}; -// Create mock data with downward trend -const createDownwardTrendData = ( - count: number, - step: 'day' | 'week' | 'month', - startValue: number = 40 -): StatsChartData => { - const dates = generateDates(count, step); - const result: StatsChartData = {}; - - dates.forEach((date, index) => { - // Decreasing trend with some randomness - const questions = Math.max(0, Math.floor(startValue - index * 2 + Math.random() * 3)); - - result[date] = { - totalQuestions: questions, - tagCounts: { - javascript: Math.floor(questions * 0.4), - react: Math.floor(questions * 0.3), - typescript: Math.floor(questions * 0.2), - html: Math.floor(questions * 0.1), - }, - tags: ['javascript', 'react', 'typescript', 'html'], + // Add to data object + data[date] = { + totalQuestions, + tagCounts, + tags: Object.keys(tagCounts), }; }); - return result; + return data; }; -// create mock data with fluctuating trend -const createFluctuatingTrendData = ( - count: number, - step: 'day' | 'week' | 'month', - baseline: number = 20 -): StatsChartData => { - const dates = generateDates(count, step); - const result: StatsChartData = {}; - - dates.forEach((date, index) => { - // Fluctuating pattern with sine wave - const amplitude = 15; - const period = count / 3; - const questions = Math.max( - 0, - Math.floor(baseline + amplitude * Math.sin((index / period) * Math.PI) + Math.random() * 5) - ); - - result[date] = { - totalQuestions: questions, - tagCounts: { - javascript: Math.floor(questions * 0.4), - react: Math.floor(questions * 0.3), - typescript: Math.floor(questions * 0.2), - html: Math.floor(questions * 0.1), - }, - tags: ['javascript', 'react', 'typescript', 'html'], +// 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 result; + return data; }; -// Create mock data sets -const dayDataUpward = createUpwardTrendData(14, 'day'); -const weekDataDownward = createDownwardTrendData(10, 'week'); -const monthDataFluctuating = createFluctuatingTrendData(12, 'month'); - -function QuestionChartWrapper({ - questionData, - step, - backgroundColor, -}: { - questionData: StatsChartData; - step: 'day' | 'week' | 'month'; - backgroundColor?: string; -}) { - return ( -
    - -
    - ); -} - -const meta = { - title: 'Charts/QuestionChart', - component: QuestionChartWrapper, - parameters: { - layout: 'centered', - backgrounds: { - default: 'dark', - values: [{ name: 'dark', value: '#090909' }], - }, - }, - tags: ['autodocs'], -} satisfies Meta; +// 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), + }; -export default meta; + // Add to data object + data[date] = { + totalQuestions, + tagCounts, + tags: Object.keys(tagCounts), + }; + }); -type Story = StoryObj; + return data; +}; -export const Default: Story = { +export const WeeklyData: Story = { args: { - questionData: dayDataUpward, + questionData: createWeeklyData(), step: 'day', - backgroundColor: 'bg-black-100', - }, - parameters: { - docs: { - description: { - story: 'Shows an upward trend in daily questions answered over the past 14 days.', - }, - }, + backgroundColor: 'bg-black', }, }; -export const WeeklyDownwardTrend: Story = { +export const MonthlyData: Story = { args: { - questionData: weekDataDownward, - step: 'week', - backgroundColor: 'bg-black-100', - }, - parameters: { - docs: { - description: { - story: 'Shows a downward trend in weekly questions answered over the past 10 weeks.', - }, - }, + questionData: createMonthlyData(), + step: 'day', + backgroundColor: 'bg-black', }, }; -export const MonthlyFluctuating: Story = { +export const DownwardTrend: Story = { args: { - questionData: monthDataFluctuating, - step: 'month', - backgroundColor: 'bg-black-100', - }, - parameters: { - docs: { - description: { - story: 'Shows a fluctuating pattern in monthly questions answered over the past 12 months.', - }, - }, + 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 index 74efa17b9..e7ee7536f 100644 --- a/src/components/charts/total-question-chart.tsx +++ b/src/components/charts/total-question-chart.tsx @@ -6,7 +6,8 @@ import NumberFlow from '@number-flow/react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { cn } from '@/lib/utils'; -import { AreaChart } from '@/components/charts/area-chart'; +import Tooltip from './tooltip'; +import { LineChart } from './line-chart'; export interface StatsChartData { [key: string]: { @@ -25,8 +26,6 @@ export default function QuestionChart({ step: 'day' | 'week' | 'month'; backgroundColor?: string; }) { - const [chartType, setChartType] = useState<'bar' | 'line'>('bar'); - const chartData = useMemo(() => { const entries = Object.entries(questionData); @@ -51,6 +50,14 @@ export default function QuestionChart({ }); }, [chartData]); + // 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) { @@ -86,15 +93,20 @@ export default function QuestionChart({ }, [orderedChartData]); // Format value for the chart to show whole numbers - const valueFormatter = (value: number) => { - return value.toFixed(0); - }; + const valueFormatter = (value: number) => value.toFixed(0); return ( - - + +
    - Questions Answered + + Last {orderedChartData.length} {step}s + +
    +
    + + Questions Answered +
    @@ -110,32 +122,33 @@ export default function QuestionChart({
    -
    - - Last {orderedChartData.length} {step}s - -
    - + {/* Check if there's data to display */} + {orderedChartData.length > 0 ? ( + } + tickGap={20} + connectNulls + autoMinValue + /> + ) : ( +
    +

    No data available

    +
    + )}
    ); diff --git a/src/lib/chart-utils.ts b/src/lib/chart-utils.ts index 9e4ece45e..797a78845 100644 --- a/src/lib/chart-utils.ts +++ b/src/lib/chart-utils.ts @@ -57,6 +57,12 @@ export const chartColors = { 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; From e8a52d029561ba19e0014e0df5c7bfc6da8da5f8 Mon Sep 17 00:00:00 2001 From: Logan Ford <110533855+Logannford@users.noreply.github.com> Date: Thu, 27 Mar 2025 20:26:33 +0000 Subject: [PATCH 21/28] progress with stories. --- src/components/charts/line-chart.stories.tsx | 55 +++++++------------ src/components/charts/line-chart.tsx | 28 ++++++++-- src/components/charts/tooltip.tsx | 2 +- .../charts/total-question-chart.tsx | 16 +++--- 4 files changed, 52 insertions(+), 49 deletions(-) diff --git a/src/components/charts/line-chart.stories.tsx b/src/components/charts/line-chart.stories.tsx index 902d24525..8d3166646 100644 --- a/src/components/charts/line-chart.stories.tsx +++ b/src/components/charts/line-chart.stories.tsx @@ -2,38 +2,6 @@ import type { Meta, StoryObj } from '@storybook/react'; import { LineChart } from './line-chart'; import Tooltip from './tooltip'; -function LineChartStory({ data }: { data: any[] }) { - return ( -
    - value.toString()} - showXAxis={true} - showYAxis={true} - showGridLines={true} - showLegend={false} - showTooltip={true} - customTooltip={(props) => } - /> -
    - ); -} - -const meta: Meta = { - title: 'Charts/LineChart', - component: LineChartStory, - parameters: { - layout: 'centered', - }, - tags: ['autodocs'], -}; - -export default meta; -type Story = StoryObj; - // Generate mock data for the past 7 days const generateMockData = () => { const today = new Date(); @@ -83,6 +51,25 @@ const mockFluctuatingData = [ // 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, @@ -124,7 +111,7 @@ export const MultipleSeries: Story = { completedQuestions: Math.floor(item.questions * 0.7), })), categories: ['questions', 'completedQuestions'], - colors: ['blue', 'emerald'], + colors: ['cyan', 'emerald'], showLegend: true, }, }; @@ -139,6 +126,6 @@ export const NoGrid: Story = { export const CustomColors: Story = { args: { ...Default.args, - colors: ['cyan', 'pink', 'amber'], + colors: ['blue', 'pink', 'amber'], }, }; diff --git a/src/components/charts/line-chart.tsx b/src/components/charts/line-chart.tsx index 552f74e7d..e24f22cb8 100644 --- a/src/components/charts/line-chart.tsx +++ b/src/components/charts/line-chart.tsx @@ -30,6 +30,24 @@ import { 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: '#0e76a8', + }; + + return colorMap[color] || '#3b82f6'; // Default to blue if color not found +}; + //#region Legend interface LegendItemProps { @@ -588,7 +606,7 @@ const LineChart = React.forwardRef((props, ref) > {showGridLines ? ( @@ -748,8 +766,6 @@ const LineChart = React.forwardRef((props, ref) cx={cxCoord} cy={cyCoord} r={5} - fill="" - stroke={stroke} strokeLinecap={strokeLinecap} strokeLinejoin={strokeLinejoin} strokeWidth={strokeWidth} @@ -780,8 +796,8 @@ const LineChart = React.forwardRef((props, ref) cx={cxCoord} cy={cyCoord} r={5} - stroke={stroke} - fill="" + stroke={stroke || '#ffffff'} + fill={getStrokeColor(categoryColors.get(dataKey) as AvailableChartColorsKeys)} strokeLinecap={strokeLinecap} strokeLinejoin={strokeLinejoin} strokeWidth={strokeWidth} @@ -802,7 +818,7 @@ const LineChart = React.forwardRef((props, ref) name={category} type="linear" dataKey={category} - stroke="" + stroke={getStrokeColor(categoryColors.get(category) as AvailableChartColorsKeys)} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" diff --git a/src/components/charts/tooltip.tsx b/src/components/charts/tooltip.tsx index 25d3154bc..a271f845b 100644 --- a/src/components/charts/tooltip.tsx +++ b/src/components/charts/tooltip.tsx @@ -70,7 +70,7 @@ export default function Tooltip({ active, payload, label }: CustomTooltipProps) className="flex items-center justify-between gap-3 mb-1" >
    -
    +

    {name}

    {displayValue}

    diff --git a/src/components/charts/total-question-chart.tsx b/src/components/charts/total-question-chart.tsx index e7ee7536f..8de2d55ea 100644 --- a/src/components/charts/total-question-chart.tsx +++ b/src/components/charts/total-question-chart.tsx @@ -131,18 +131,18 @@ export default function QuestionChart({ data={orderedChartData} index="date" categories={['questions']} - colors={['blue']} + colors={['cyan']} valueFormatter={valueFormatter} - showXAxis - showYAxis - showGridLines + showXAxis={true} + showYAxis={true} + showGridLines={true} yAxisWidth={40} - showLegend - showTooltip + showLegend={false} + showTooltip={true} customTooltip={(props) => } tickGap={20} - connectNulls - autoMinValue + connectNulls={true} + autoMinValue={true} /> ) : (
    From 55240dfc0847619dcb4ab8bd1d6bddbcaf73f38e Mon Sep 17 00:00:00 2001 From: Logan Ford <110533855+Logannford@users.noreply.github.com> Date: Thu, 27 Mar 2025 20:34:58 +0000 Subject: [PATCH 22/28] minor style changes --- .../charts/total-question-chart.tsx | 47 ++++++++++++------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/src/components/charts/total-question-chart.tsx b/src/components/charts/total-question-chart.tsx index 8de2d55ea..47e746581 100644 --- a/src/components/charts/total-question-chart.tsx +++ b/src/components/charts/total-question-chart.tsx @@ -103,27 +103,38 @@ export default function QuestionChart({ Last {orderedChartData.length} {step}s
    -
    +
    Questions Answered -
    -
    +
    +

    + {trend.isUp ? '+' : '-'} {orderedChartData[orderedChartData.length - 1].questions}{' '} + questions +

    +
    - % + ( + %) - {trend.isUp && !trend.isNeutral ? ( - - ) : !trend.isNeutral ? ( - - ) : ( - - )}
    +

    vs last month

    + {/** step changer */} +
    - + {/* Check if there's data to display */} {orderedChartData.length > 0 ? ( } tickGap={20} - connectNulls={true} - autoMinValue={true} + connectNulls + autoMinValue /> ) : (
    From d4354f486f7eeb0e43e7877e80bab2ed0202ac73 Mon Sep 17 00:00:00 2001 From: Logan Ford <110533855+Logannford@users.noreply.github.com> Date: Thu, 27 Mar 2025 20:57:45 +0000 Subject: [PATCH 23/28] feat: adding spark charts (will be used elsewhere in the app) --- src/components/charts/line-chart.tsx | 2 +- .../charts/spark-bar-chart.stories.tsx | 117 +++++++ src/components/charts/spark-chart.stories.tsx | 253 ++++++++++++++ src/components/charts/spark-chart.tsx | 319 ++++++++++++++++++ .../charts/spark-line-chart.stories.tsx | 129 +++++++ .../charts/total-question-chart.tsx | 130 +++++-- src/components/ui/select.tsx | 2 +- 7 files changed, 915 insertions(+), 37 deletions(-) create mode 100644 src/components/charts/spark-bar-chart.stories.tsx create mode 100644 src/components/charts/spark-chart.stories.tsx create mode 100644 src/components/charts/spark-chart.tsx create mode 100644 src/components/charts/spark-line-chart.stories.tsx diff --git a/src/components/charts/line-chart.tsx b/src/components/charts/line-chart.tsx index e24f22cb8..a0e395a24 100644 --- a/src/components/charts/line-chart.tsx +++ b/src/components/charts/line-chart.tsx @@ -675,7 +675,7 @@ const LineChart = React.forwardRef((props, ref) wrapperStyle={{ outline: 'none' }} isAnimationActive={true} animationDuration={100} - cursor={{ stroke: '#d1d5db', strokeWidth: 1 }} + cursor={{ stroke: '#d1d5db', strokeWidth: 1, strokeDasharray: '5 5' }} offset={20} position={{ y: 0 }} content={({ active, payload, label }) => { 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..489eaaa1c --- /dev/null +++ b/src/components/charts/spark-bar-chart.stories.tsx @@ -0,0 +1,117 @@ +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 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..ba7fd9527 --- /dev/null +++ b/src/components/charts/spark-chart.stories.tsx @@ -0,0 +1,253 @@ +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 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..0fe703475 --- /dev/null +++ b/src/components/charts/spark-chart.tsx @@ -0,0 +1,319 @@ +// 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'; + +//#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..61371f1ff --- /dev/null +++ b/src/components/charts/spark-line-chart.stories.tsx @@ -0,0 +1,129 @@ +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 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/total-question-chart.tsx b/src/components/charts/total-question-chart.tsx index 47e746581..db110dfee 100644 --- a/src/components/charts/total-question-chart.tsx +++ b/src/components/charts/total-question-chart.tsx @@ -8,6 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { cn } from '@/lib/utils'; import Tooltip from './tooltip'; import { LineChart } from './line-chart'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; export interface StatsChartData { [key: string]: { @@ -19,13 +20,31 @@ export interface StatsChartData { export default function QuestionChart({ questionData, - step, + step: initialStep, backgroundColor, }: { questionData: StatsChartData; 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(() => { const entries = Object.entries(questionData); @@ -45,10 +64,17 @@ export default function QuestionChart({ // order the chart data by the date. Ensuring that the oldest date is first const orderedChartData = useMemo(() => { - return [...chartData].sort((a, b) => { + // 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(); }); - }, [chartData]); + + // 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); @@ -95,44 +121,54 @@ export default function QuestionChart({ // Format value for the chart to show whole numbers const valueFormatter = (value: number) => value.toFixed(0); + // 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'; + return ( - -
    - - Last {orderedChartData.length} {step}s - -
    +
    - - Questions Answered - -
    -

    - {trend.isUp ? '+' : '-'} {orderedChartData[orderedChartData.length - 1].questions}{' '} - questions -

    -
    - - ( - %) - -
    -

    vs last month

    +
    + {getStepDisplayText()} +
    +
    + + Questions Answered +
    {/** step changer */} -
    +
    + +
    {/* Check if there's data to display */} @@ -160,6 +196,30 @@ export default function QuestionChart({

    No data available

    )} +
    +

    + Total questions () +

    +
    +

    + {trend.isUp ? '+' : '-'} + {orderedChartData[orderedChartData.length - 1]?.questions || 0} questions +

    +
    + + ( + %) + +
    +

    vs first {step}

    +
    +
    ); 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} - + )); From 29ba73272338966193397df77f74203ac662a961 Mon Sep 17 00:00:00 2001 From: Logan Ford <110533855+Logannford@users.noreply.github.com> Date: Thu, 27 Mar 2025 21:02:40 +0000 Subject: [PATCH 24/28] feat: tooltip style changes & total question chart style changes --- src/components/charts/tooltip.tsx | 12 ++++-- .../charts/total-question-chart.tsx | 37 +++++++++---------- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/src/components/charts/tooltip.tsx b/src/components/charts/tooltip.tsx index a271f845b..138d7fdbe 100644 --- a/src/components/charts/tooltip.tsx +++ b/src/components/charts/tooltip.tsx @@ -1,4 +1,6 @@ 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 { @@ -39,8 +41,10 @@ export default function Tooltip({ active, payload, label }: CustomTooltipProps) } return ( -
    -

    {formattedLabel}

    +
    +

    {formattedLabel}

    + + {payload.map((entry, index) => { // Get the display name (try different properties) @@ -67,11 +71,11 @@ export default function Tooltip({ active, payload, label }: CustomTooltipProps) return (
    -

    {name}

    +

    {capitalise(name)}:

    {displayValue}

    diff --git a/src/components/charts/total-question-chart.tsx b/src/components/charts/total-question-chart.tsx index db110dfee..d257a37cb 100644 --- a/src/components/charts/total-question-chart.tsx +++ b/src/components/charts/total-question-chart.tsx @@ -197,27 +197,26 @@ export default function QuestionChart({
    )}
    -

    - Total questions () -

    -
    -

    - {trend.isUp ? '+' : '-'} - {orderedChartData[orderedChartData.length - 1]?.questions || 0} questions +

    +

    + {orderedChartData[orderedChartData.length - 1]?.questions || 0} questions answered

    -
    - - ( - %) - +
    +
    + {trend.isUp ? '+' : '-'} + + ( + %) + +
    +

    vs last month

    -

    vs first {step}

    From 72f1eb4fa601be2658461be8b46b9607c85587e6 Mon Sep 17 00:00:00 2001 From: Logan Ford <110533855+Logannford@users.noreply.github.com> Date: Thu, 27 Mar 2025 21:11:36 +0000 Subject: [PATCH 25/28] more work with total questions chart. --- .../(app)/(default_layout)/statistics/page.tsx | 3 +++ src/components/charts/line-chart.tsx | 1 + src/components/charts/tooltip.tsx | 4 +--- src/components/charts/total-question-chart.tsx | 17 ++++++++--------- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/app/(app)/(default_layout)/statistics/page.tsx b/src/app/(app)/(default_layout)/statistics/page.tsx index 00dcd038d..e62b492e2 100644 --- a/src/app/(app)/(default_layout)/statistics/page.tsx +++ b/src/app/(app)/(default_layout)/statistics/page.tsx @@ -89,6 +89,9 @@ export default async function StatisticsPage({
    +
    + +
    ); diff --git a/src/components/charts/line-chart.tsx b/src/components/charts/line-chart.tsx index a0e395a24..bf5ed48be 100644 --- a/src/components/charts/line-chart.tsx +++ b/src/components/charts/line-chart.tsx @@ -609,6 +609,7 @@ const LineChart = React.forwardRef((props, ref) className={cx('stroke-black-50 stroke-1')} horizontal={true} vertical={false} + strokeDasharray="3 3" /> ) : null} -

    {formattedLabel}

    - - +

    {formattedLabel}

    {payload.map((entry, index) => { // Get the display name (try different properties) diff --git a/src/components/charts/total-question-chart.tsx b/src/components/charts/total-question-chart.tsx index d257a37cb..017ec80d2 100644 --- a/src/components/charts/total-question-chart.tsx +++ b/src/components/charts/total-question-chart.tsx @@ -23,7 +23,7 @@ export default function QuestionChart({ step: initialStep, backgroundColor, }: { - questionData: StatsChartData; + questionData: StatsChartData | null; step: 'day' | 'week' | 'month'; backgroundColor?: string; }) { @@ -46,6 +46,8 @@ export default function QuestionChart({ }; const chartData = useMemo(() => { + if (!questionData) return []; + const entries = Object.entries(questionData); // Sort entries by date - latest first @@ -137,10 +139,10 @@ export default function QuestionChart({ } }; - const textSize = 'text-xl font-medium leading-none'; + const textSize = 'text-xl font-medium leading-none font-onest'; return ( - +
    @@ -182,7 +184,7 @@ export default function QuestionChart({ valueFormatter={valueFormatter} showXAxis={false} showYAxis={false} - showGridLines={false} + showGridLines={true} yAxisWidth={40} showLegend={false} showTooltip @@ -196,11 +198,8 @@ export default function QuestionChart({

    No data available

    )} -
    -
    -

    - {orderedChartData[orderedChartData.length - 1]?.questions || 0} questions answered -

    +
    +
    Date: Thu, 27 Mar 2025 21:22:08 +0000 Subject: [PATCH 26/28] style amends --- src/app/globals.css | 12 ++++++++-- .../app/statistics/question-history.tsx | 2 +- src/components/charts/line-chart.stories.tsx | 7 ++++++ src/components/charts/line-chart.tsx | 22 +++++++++++-------- .../charts/spark-bar-chart.stories.tsx | 7 ++++++ src/components/charts/spark-chart.stories.tsx | 7 ++++++ src/components/charts/spark-chart.tsx | 16 +++++++++++--- .../charts/spark-line-chart.stories.tsx | 7 ++++++ src/components/charts/tooltip.tsx | 18 +++++++++++++-- .../charts/total-question-chart.tsx | 13 ++++++----- tailwind.config.ts | 17 ++++++++++++++ 11 files changed, 106 insertions(+), 22 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 0b6c240fe..654c4750d 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/statistics/question-history.tsx b/src/components/app/statistics/question-history.tsx index b63b836c1..0879058f7 100644 --- a/src/components/app/statistics/question-history.tsx +++ b/src/components/app/statistics/question-history.tsx @@ -160,7 +160,7 @@ export default function QuestionHistory({ const correctCount = recentAnswers.filter((answer) => answer.correctAnswer).length; return ( - +
    diff --git a/src/components/charts/line-chart.stories.tsx b/src/components/charts/line-chart.stories.tsx index 8d3166646..a9e418dff 100644 --- a/src/components/charts/line-chart.stories.tsx +++ b/src/components/charts/line-chart.stories.tsx @@ -129,3 +129,10 @@ export const CustomColors: Story = { 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 index bf5ed48be..0aaee2f59 100644 --- a/src/components/charts/line-chart.tsx +++ b/src/components/charts/line-chart.tsx @@ -42,7 +42,7 @@ const getStrokeColor = (color: AvailableChartColorsKeys): string => { pink: '#ec4899', lime: '#84cc16', fuchsia: '#d946ef', - accent: '#0e76a8', + accent: '#5b61d6', }; return colorMap[color] || '#3b82f6'; // Default to blue if color not found @@ -393,18 +393,18 @@ const ChartTooltip = ({ active, payload, label, valueFormatter }: ChartTooltipPr // base 'rounded-md border text-sm shadow-md', // border color - 'border-gray-200 dark:border-gray-800', + 'border-black-50', // background color - 'bg-white dark:bg-gray-950' + 'bg-black' )} > -
    +

    {label} @@ -418,7 +418,7 @@ const ChartTooltip = ({ active, payload, label, valueFormatter }: ChartTooltipPr aria-hidden="true" className={cx( 'h-[3px] w-3.5 shrink-0 rounded-full', - getColorClassName(color, 'bg') + color === 'accent' ? 'bg-accent' : getColorClassName(color, 'bg') )} />

    {category} @@ -437,7 +437,7 @@ const ChartTooltip = ({ active, payload, label, valueFormatter }: ChartTooltipPr // base 'whitespace-nowrap text-right font-medium tabular-nums', // text color - 'text-gray-900 dark:text-gray-50' + 'text-white' )} > {valueFormatter(value)} @@ -676,7 +676,11 @@ const LineChart = React.forwardRef((props, ref) wrapperStyle={{ outline: 'none' }} isAnimationActive={true} animationDuration={100} - cursor={{ stroke: '#d1d5db', strokeWidth: 1, strokeDasharray: '5 5' }} + cursor={{ + stroke: 'hsl(var(--accent))', + strokeWidth: 1, + strokeDasharray: '5 5', + }} offset={20} position={{ y: 0 }} content={({ active, payload, label }) => { diff --git a/src/components/charts/spark-bar-chart.stories.tsx b/src/components/charts/spark-bar-chart.stories.tsx index 489eaaa1c..83fc81e2e 100644 --- a/src/components/charts/spark-bar-chart.stories.tsx +++ b/src/components/charts/spark-bar-chart.stories.tsx @@ -86,6 +86,13 @@ export const Default: SparkBarChartStory = { }, }; +export const AccentColor: SparkBarChartStory = { + args: { + ...Default.args, + colors: ['accent'], + }, +}; + export const TrendingUp: SparkBarChartStory = { args: { ...Default.args, diff --git a/src/components/charts/spark-chart.stories.tsx b/src/components/charts/spark-chart.stories.tsx index ba7fd9527..30a2f0e27 100644 --- a/src/components/charts/spark-chart.stories.tsx +++ b/src/components/charts/spark-chart.stories.tsx @@ -101,6 +101,13 @@ export const Default: SparkAreaChartStory = { }, }; +export const AccentColor: SparkAreaChartStory = { + args: { + ...Default.args, + colors: ['accent'], + }, +}; + export const TrendingUp: SparkAreaChartStory = { args: { ...Default.args, diff --git a/src/components/charts/spark-chart.tsx b/src/components/charts/spark-chart.tsx index 0fe703475..7759c79c1 100644 --- a/src/components/charts/spark-chart.tsx +++ b/src/components/charts/spark-chart.tsx @@ -26,6 +26,14 @@ import { } 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 { @@ -136,7 +144,9 @@ const SparkAreaChart = React.forwardRef( name={category} type="linear" dataKey={category} - stroke="" + stroke={getDirectColor( + categoryColors.get(category) as AvailableChartColorsKeys + )} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" @@ -221,7 +231,7 @@ const SparkLineChart = React.forwardRef( name={category} type="linear" dataKey={category} - stroke="" + stroke={getDirectColor(categoryColors.get(category) as AvailableChartColorsKeys)} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" @@ -305,7 +315,7 @@ const SparkBarChart = React.forwardRef((props, fo dataKey={category} stackId={stacked ? 'stack' : undefined} isAnimationActive={false} - fill="" + fill={getDirectColor(categoryColors.get(category) as AvailableChartColorsKeys) || ''} /> ))} diff --git a/src/components/charts/spark-line-chart.stories.tsx b/src/components/charts/spark-line-chart.stories.tsx index 61371f1ff..bf5569e55 100644 --- a/src/components/charts/spark-line-chart.stories.tsx +++ b/src/components/charts/spark-line-chart.stories.tsx @@ -97,6 +97,13 @@ export const Default: SparkLineChartStory = { }, }; +export const AccentColor: SparkLineChartStory = { + args: { + ...Default.args, + colors: ['accent'], + }, +}; + export const TrendingUp: SparkLineChartStory = { args: { ...Default.args, diff --git a/src/components/charts/tooltip.tsx b/src/components/charts/tooltip.tsx index 849e848a8..11bcf1d1e 100644 --- a/src/components/charts/tooltip.tsx +++ b/src/components/charts/tooltip.tsx @@ -24,6 +24,20 @@ interface CustomTooltipProps { 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; @@ -63,8 +77,8 @@ export default function Tooltip({ active, payload, label }: CustomTooltipProps) } } - // Get color from available properties - const color = entry.color || entry.stroke || entry.fill || '#3b82f6'; + // Get the appropriate color + const color = getTooltipColor(entry); return (

    value.toFixed(0); + // 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 = () => { @@ -180,11 +183,11 @@ export default function QuestionChart({ data={orderedChartData} index="date" categories={['questions']} - colors={['cyan']} + colors={['accent']} valueFormatter={valueFormatter} showXAxis={false} showYAxis={false} - showGridLines={true} + showGridLines={false} yAxisWidth={40} showLegend={false} showTooltip @@ -198,7 +201,7 @@ export default function QuestionChart({

    No data available

    )} -
    +
    [`--${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, }); } From 6f9f46f050459a38674b245cf2f66e88773bfded Mon Sep 17 00:00:00 2001 From: Logan Ford <110533855+Logannford@users.noreply.github.com> Date: Thu, 27 Mar 2025 21:43:52 +0000 Subject: [PATCH 27/28] feat: adds bar-list and story added. --- .../charts/bar-list-chart.stories.tsx | 188 ++++++++++++++++++ src/components/charts/bar-list-chart.tsx | 169 ++++++++++++++++ .../charts/total-question-chart.tsx | 2 +- src/lib/utils.ts | 9 + 4 files changed, 367 insertions(+), 1 deletion(-) create mode 100644 src/components/charts/bar-list-chart.stories.tsx create mode 100644 src/components/charts/bar-list-chart.tsx 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..e7f9a6014 --- /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'; + +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/charts/total-question-chart.tsx b/src/components/charts/total-question-chart.tsx index ac106403c..984f6d23f 100644 --- a/src/components/charts/total-question-chart.tsx +++ b/src/components/charts/total-question-chart.tsx @@ -187,7 +187,7 @@ export default function QuestionChart({ valueFormatter={valueFormatter} showXAxis={false} showYAxis={false} - showGridLines={false} + showGridLines yAxisWidth={40} showLegend={false} showTooltip 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', +]; From b542d5d3b189c2be7838f1a370dd12fd952eda2a Mon Sep 17 00:00:00 2001 From: Logan Ford <110533855+Logannford@users.noreply.github.com> Date: Thu, 27 Mar 2025 22:40:38 +0000 Subject: [PATCH 28/28] feat: adding separators to stats charts --- .../app/statistics/question-history.tsx | 5 +- src/components/charts/bar-list-chart.tsx | 8 +- src/components/charts/tags-chart.stories.tsx | 137 ++++++++++++++++++ src/components/charts/tags-chart.tsx | 36 +++++ .../charts/total-question-chart.tsx | 2 + 5 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 src/components/charts/tags-chart.stories.tsx create mode 100644 src/components/charts/tags-chart.tsx diff --git a/src/components/app/statistics/question-history.tsx b/src/components/app/statistics/question-history.tsx index 0879058f7..11191fcb0 100644 --- a/src/components/app/statistics/question-history.tsx +++ b/src/components/app/statistics/question-history.tsx @@ -13,6 +13,7 @@ import { 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 { @@ -182,7 +183,9 @@ export default function QuestionHistory({ {answeredCount > 0 ? ' Great work!' : ' Start answering to track your progress.'} - + + + {recentAnswers.length === 0 ? (

    No recent questions found.

    diff --git a/src/components/charts/bar-list-chart.tsx b/src/components/charts/bar-list-chart.tsx index e7f9a6014..c0d23c2d2 100644 --- a/src/components/charts/bar-list-chart.tsx +++ b/src/components/charts/bar-list-chart.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { cn as cx, focusRing } from '@/lib/utils'; +import { ChevronRight } from 'lucide-react'; type Bar = T & { key?: string; @@ -102,19 +103,18 @@ function BarListInner( href={item.href} className={cx( // base - 'truncate whitespace-nowrap rounded text-sm font-onest', + 'group truncate whitespace-nowrap rounded text-sm font-onest flex items-center gap-2', // text color 'text-gray-50', // hover - 'hover:underline hover:underline-offset-2', - // focus - focusRing + 'hover:underline hover:underline-offset-2' )} target="_blank" rel="noreferrer" onClick={(event) => event.stopPropagation()} > {item.name} + ) : (

    ( +

    + +
    + ), + ], +} 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/total-question-chart.tsx b/src/components/charts/total-question-chart.tsx index 984f6d23f..cb8ef29dc 100644 --- a/src/components/charts/total-question-chart.tsx +++ b/src/components/charts/total-question-chart.tsx @@ -9,6 +9,7 @@ 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]: { @@ -175,6 +176,7 @@ export default function QuestionChart({
    + {/* Check if there's data to display */} {orderedChartData.length > 0 ? (