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 (
+ No difficulty data available due to lack of questions answered +
+ +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}
+No recent questions found.
++ + Start answering questions + +
+\nTypically, HTTP codes that begin with '2' indicate that the request was successfully received, understood, and accepted.\n
\n\nThe HTTP status code '200' means the request was successful, indicating that the server returned the requested resource.\n
\nNo recent questions found.
- - Start answering questions - +
No recent questions found.
-- -
+No recent questions found.
+\nTypically, HTTP codes that begin with '2' indicate that the request was successfully received, understood, and accepted.\n
\n\nThe HTTP status code '200' means the request was successful, indicating that the server returned the requested resource.\n
\n+ {name} +
++ {label} +
++ {category} +
++ {valueFormatter(value)} +
++ {name} +
++ {label} +
++ {category} +
++ {valueFormatter(value)} +
+{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}
+No data available
+{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) =>+ {trend.isUp ? '+' : '-'} {orderedChartData[orderedChartData.length - 1].questions}{' '} + questions +
+vs last month
- {trend.isUp ? '+' : '-'} {orderedChartData[orderedChartData.length - 1].questions}{' '} - questions -
-vs last month
+No data available
+ Total questions () +
++ {trend.isUp ? '+' : '-'} + {orderedChartData[orderedChartData.length - 1]?.questions || 0} questions +
+vs first {step}
+{formattedLabel}
+{formattedLabel}
+ +{name}
+{capitalise(name)}:
{displayValue}
- Total questions () -
-- {trend.isUp ? '+' : '-'} - {orderedChartData[orderedChartData.length - 1]?.questions || 0} questions +
+ {orderedChartData[orderedChartData.length - 1]?.questions || 0} questions answered
-vs last month
vs first {step}
{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 ( -No data available
- {orderedChartData[orderedChartData.length - 1]?.questions || 0} questions answered -
+{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 No data available
+ {item.name}
+
+ {valueFormatter(item.value)}
+ No recent questions found. (
+ {title}
+