diff --git a/client/src/components/CourseInfo.tsx b/client/src/components/CourseInfo.tsx index af783257..832c4351 100644 --- a/client/src/components/CourseInfo.tsx +++ b/client/src/components/CourseInfo.tsx @@ -1,62 +1,131 @@ import { ExternalLink } from 'react-feather'; - +import { HiChartBar, HiChartPie } from 'react-icons/hi'; +import { useState } from 'react'; import { Course } from '../model/Course'; import { CourseTerms } from './CourseTerms'; import { RatingInfo } from './RatingInfo'; - -type ChartsProps = { - numReviews?: number; - rating: number; - difficulty: number; -}; - -const Charts = ({ numReviews, rating, difficulty }: ChartsProps) => { - if (numReviews === undefined) return null; - - return numReviews ? ( - <> - - - - ) : ( -
- No reviews have been left for this course yet. Be the first! -
- ); -}; +import { Review } from '../model/Review'; +import { countRatings } from '../lib/utils'; type CourseInfoProps = { course: Course; - rating: number; - difficulty: number; - numReviews?: number; + reviews: Review[]; }; -export const CourseInfo = ({ - course, - rating, - difficulty, - numReviews, -}: CourseInfoProps) => { +export const CourseInfo = ({ course, reviews }: CourseInfoProps) => { + const ratingMap: number[] = countRatings('rating', reviews); + const difficultyMap: number[] = countRatings('difficulty', reviews); + const numReviews = reviews.length; + + const [chartType, setChartType] = useState<'pie' | 'histogram'>('pie'); + return ( -
-
-
-

- {course._id} -

- {course.url ? ( - - +
+
+
+ +

+ {course.title} +

+
+ + +
+ setChartType('pie')} + size={30} + /> + setChartType('histogram')} + size={30} + /> +
+
+ +

+ {course.description} +

+

+ {numReviews} reviews +

+
+
+
+ + +
+
+ setChartType('pie')} + size={30} + /> + setChartType('histogram')} + size={30} /> - - ) : null} +
+

{course.title} diff --git a/client/src/components/RatingHistogram.tsx b/client/src/components/RatingHistogram.tsx new file mode 100644 index 00000000..55617bb4 --- /dev/null +++ b/client/src/components/RatingHistogram.tsx @@ -0,0 +1,61 @@ +import { + Chart, + LinearScale, + CategoryScale, + BarElement, + Tooltip, +} from 'chart.js'; +import { Bar } from 'react-chartjs-2'; + +Chart.register(LinearScale, CategoryScale, BarElement, Tooltip); + +export const RatingHistogram = ({ ratings }: { ratings: number[] }) => { + const data = { + labels: [5, 4, 3, 2, 1], + datasets: [ + { + data: ratings, + backgroundColor: '#dc2626', + borderWidth: 0, + }, + ], + }; + + const options = { + indexAxis: 'y', + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: true, + callbacks: { + label: (context: any) => { + const count = ratings[context.dataIndex]; + return `Number of ratings: ${count}`; + }, + }, + }, + }, + scales: { + x: { + beginAtZero: true, + max: Math.max(...ratings) + 1, + }, + y: { + beginAtZero: true, + ticks: { + precision: 0, + }, + }, + }, + responsive: true, + maintainAspectRatio: false, + } as const; + + return ( +
+ +
+ ); +}; diff --git a/client/src/components/RatingInfo.tsx b/client/src/components/RatingInfo.tsx index d1b652b9..16df7e9a 100644 --- a/client/src/components/RatingInfo.tsx +++ b/client/src/components/RatingInfo.tsx @@ -1,17 +1,50 @@ import { RatingPieChart } from './RatingPieChart'; +import { RatingHistogram } from './RatingHistogram'; +import _ from 'lodash'; -type RatingInfoProps = { +export const RatingInfo = ({ + title, + chartType, + numReviews, + ratings, + content, +}: { title: string; - rating: number; -}; + chartType: 'pie' | 'histogram'; + numReviews: number; + ratings: number[]; + content?: string; +}) => { + const averageRating = + _.sum(ratings.map((value, index) => value * (index + 1))) / numReviews; + const pieChart = ( +
+ +
+ ); + + const histogramChart = ( +
+ +
+ ); -export const RatingInfo = ({ title, rating }: RatingInfoProps) => { return ( -
-

+
+

{title}

- + {numReviews > 0 ? ( + chartType === 'pie' ? ( + pieChart + ) : ( + histogramChart + ) + ) : ( +
+ {content ?? 'No reviews have been left yet. Be the first!'} +
+ )}
); }; diff --git a/client/src/components/RatingPieChart.tsx b/client/src/components/RatingPieChart.tsx index e896e28d..8b679f77 100644 --- a/client/src/components/RatingPieChart.tsx +++ b/client/src/components/RatingPieChart.tsx @@ -1,28 +1,41 @@ -import { PieChart } from 'react-minimal-pie-chart'; +import { Chart, ArcElement } from 'chart.js'; +import { Doughnut } from 'react-chartjs-2'; -type RatingPieChartProps = { - rating: number; -}; +Chart.register(ArcElement); + +export const RatingPieChart = ({ + averageRating, +}: { + averageRating: number; +}) => { + const data = { + labels: ['Average Rating', 'Remaining'], + datasets: [ + { + data: [averageRating, 5 - averageRating], + backgroundColor: ['#dc2626', '#e4e4e7'], + borderWidth: 0, + }, + ], + }; + + const options = { + plugins: { + legend: { + display: false, + }, + }, + cutout: '67%', // The size of the inner circle + maintainAspectRatio: false, + responsive: true, + }; -export const RatingPieChart = ({ rating }: RatingPieChartProps) => { return (
-
- -
- {Math.round(rating * 100) / 100} / 5 +
+ +
+ {Math.round(averageRating * 100) / 100} / 5
diff --git a/client/src/lib/utils.ts b/client/src/lib/utils.ts index 9d762478..686a4389 100644 --- a/client/src/lib/utils.ts +++ b/client/src/lib/utils.ts @@ -1,6 +1,7 @@ import { Course } from '../model/Course'; import { Instructor } from '../model/Instructor'; import { Schedule } from '../model/Schedule'; +import { Review } from '../model/Review'; export const uniqueTermInstructors = (course: Course) => { const termInstructors = course.instructors.filter((i) => @@ -72,3 +73,23 @@ export const sortSchedulesByBlocks = (schedules: Schedule[]) => { export const getUrl = (): string => { return import.meta.env.VITE_API_URL ?? ''; }; + + +export const countRatings = ( + type: 'rating' | 'difficulty', + reviews: Review[] +) => { + const ratings: number[] = [0, 0, 0, 0, 0]; + const target = (r: Review) => { + switch (type) { + case 'rating': + return r.rating; + case 'difficulty': + return r.difficulty; + } + }; + + reviews.forEach((r: Review) => ratings[target(r) - 1]++); + + return ratings; +}; diff --git a/client/src/pages/CoursePage.tsx b/client/src/pages/CoursePage.tsx index 4c42fa38..19f3f6a7 100644 --- a/client/src/pages/CoursePage.tsx +++ b/client/src/pages/CoursePage.tsx @@ -32,7 +32,7 @@ export const CoursePage = () => { const [addReviewOpen, setAddReviewOpen] = useState(false); const [alertMessage, setAlertMessage] = useState(''); const [alertStatus, setAlertStatus] = useState(null); - const [allReviews, setAllReviews] = useState(undefined); + const [allReviews, setAllReviews] = useState([]); const [course, setCourse] = useState(); const [editReviewOpen, setEditReviewOpen] = useState(false); const [key, setKey] = useState(0); @@ -122,21 +122,12 @@ export const CoursePage = () => { localStorage.removeItem(course._id); }; - const userReview = allReviews?.find((r) => r.userId === user?.id); - - const averageRating = - _.sumBy(allReviews, (r) => r.rating) / (allReviews ?? []).length; - - const averageDifficulty = - _.sumBy(allReviews, (r) => r.difficulty) / (allReviews ?? []).length; - + const userReview = allReviews.find((r) => r.userId === user?.id); return (
diff --git a/client/src/pages/Instructor.tsx b/client/src/pages/Instructor.tsx index e78bcb0d..fb76d6e7 100644 --- a/client/src/pages/Instructor.tsx +++ b/client/src/pages/Instructor.tsx @@ -1,5 +1,8 @@ import _ from 'lodash'; import { Fragment, useEffect, useState } from 'react'; +import { HiChartBar, HiChartPie } from 'react-icons/hi'; +import { countRatings } from '../lib/utils'; +import { Instructor as InstructorType } from '../model/Instructor'; import { Link, useParams } from 'react-router-dom'; import { CourseReview } from '../components/CourseReview'; @@ -7,7 +10,6 @@ import { Layout } from '../components/Layout'; import { RatingInfo } from '../components/RatingInfo'; import { useAuth } from '../hooks/useAuth'; import { fetchClient } from '../lib/fetchClient'; -import { Instructor as InstructorType } from '../model/Instructor'; import { Review } from '../model/Review'; import { Loading } from './Loading'; import { NotFound } from './NotFound'; @@ -18,6 +20,7 @@ export const Instructor = () => { const [reviews, setReviews] = useState([]); const [showAllReviews, setShowAllReviews] = useState(false); + const [chartType, setChartType] = useState<'pie' | 'histogram'>('pie'); const [instructor, setInstructor] = useState< InstructorType | undefined | null @@ -41,12 +44,12 @@ export const Instructor = () => { if (instructor === undefined) return ; if (instructor === null) return ; + const ratingMap = countRatings('rating', reviews); + const difficultyMap = countRatings('difficulty', reviews); + const userReview = reviews.find((r) => r.userId === user?.id); const uniqueReviews = _.uniqBy(reviews, (r) => r.courseId); - const averageRating = _.sumBy(reviews, (r) => r.rating) / reviews.length; - const averageDifficulty = - _.sumBy(reviews, (r) => r.difficulty) / reviews.length; return ( @@ -59,13 +62,41 @@ export const Instructor = () => { {params.name && decodeURIComponent(params.name)}

-
- {uniqueReviews.length ? ( - <> - - - - ) : null} +
+ + +
+
+ setChartType('pie')} + size={30} + /> + setChartType('histogram')} + size={30} + />

{uniqueReviews.length ? ( @@ -85,13 +116,43 @@ export const Instructor = () => { )}

-
- {uniqueReviews.length ? ( - <> - - - - ) : null} +
+
+ + +
+
+ setChartType('pie')} + size={30} + /> + setChartType('histogram')} + size={30} + /> +

diff --git a/package-lock.json b/package-lock.json index 70fd8c39..c0df1566 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,11 +11,13 @@ "@fontsource/inter": "^4.5.15", "@headlessui/react": "^1.7.13", "@heroicons/react": "^2.0.16", + "chart.js": "^4.3.0", "date-fns": "^2.29.3", "formik": "^2.2.9", "formik-persist-values": "^1.4.1", "lodash": "^4.17.21", "react": "^18.2.0", + "react-chartjs-2": "^5.2.0", "react-dom": "^18.2.0", "react-feather": "^2.0.10", "react-icons": "^4.8.0", @@ -976,6 +978,11 @@ "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", "dev": true }, + "node_modules/@kurkle/color": { + "version": "0.3.2", + "resolved": "https://registry.npmmirror.com/@kurkle/color/-/color-0.3.2.tgz", + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1872,6 +1879,17 @@ "node": ">=4" } }, + "node_modules/chart.js": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/chart.js/-/chart.js-4.3.0.tgz", + "integrity": "sha512-ynG0E79xGfMaV2xAHdbhwiPLczxnNNnasrmPEXriXsPJGjmhOBYzFVEsB65w2qMDz+CaBJJuJD0inE/ab/h36g==", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=7" + } + }, "node_modules/check-more-types": { "version": "2.24.0", "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", @@ -5480,6 +5498,15 @@ "node": ">=0.10.0" } }, + "node_modules/react-chartjs-2": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz", + "integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", diff --git a/package.json b/package.json index 77250654..4af221e4 100644 --- a/package.json +++ b/package.json @@ -21,11 +21,13 @@ "@fontsource/inter": "^4.5.15", "@headlessui/react": "^1.7.13", "@heroicons/react": "^2.0.16", + "chart.js": "^4.3.0", "date-fns": "^2.29.3", "formik": "^2.2.9", "formik-persist-values": "^1.4.1", "lodash": "^4.17.21", "react": "^18.2.0", + "react-chartjs-2": "^5.2.0", "react-dom": "^18.2.0", "react-feather": "^2.0.10", "react-icons": "^4.8.0",