From 5cbe0b709ec9fd5ba12dcc7d2c756765a2b9ab03 Mon Sep 17 00:00:00 2001 From: "Jenna (Ju Hee) Han" <62906996+jenna5376@users.noreply.github.com> Date: Sat, 27 Apr 2024 16:20:26 -0400 Subject: [PATCH] feat: added stats component to profile page (#54301) --- client/i18n/locales/english/translations.json | 2 +- .../profile/components/heat-map.test.tsx | 14 -- .../profile/components/heat-map.tsx | 58 +------ .../components/profile/components/heatmap.css | 8 - .../components/profile/components/stats.css | 22 +++ .../profile/components/stats.test.tsx | 24 +++ .../components/profile/components/stats.tsx | 142 ++++++++++++++++++ client/src/components/profile/profile.tsx | 18 +-- e2e/profile.spec.ts | 2 +- 9 files changed, 199 insertions(+), 91 deletions(-) create mode 100644 client/src/components/profile/components/stats.css create mode 100644 client/src/components/profile/components/stats.test.tsx create mode 100644 client/src/components/profile/components/stats.tsx diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index a4c8b2ed956..f7a403e4223 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -310,7 +310,7 @@ "tweet": "I just earned the {{certTitle}} certification @freeCodeCamp! Check it out here: {{certURL}}", "avatar": "{{username}}'s avatar", "joined": "Joined {{date}}", - "total-points": "Number of points: {{count}}", + "total-points": "Total Points:", "points": "{{count}} point on {{date}}", "points_plural": "{{count}} points on {{date}}", "page-number": "{{pageNumber}} of {{totalPages}}" diff --git a/client/src/components/profile/components/heat-map.test.tsx b/client/src/components/profile/components/heat-map.test.tsx index c2a8c5ba0cf..c8911fad046 100644 --- a/client/src/components/profile/components/heat-map.test.tsx +++ b/client/src/components/profile/components/heat-map.test.tsx @@ -65,18 +65,4 @@ describe('', () => { screen.getByText(`${startOfCalendar} - ${endOfCalendar}`) ).toBeInTheDocument(); }); - - it('calculates the correct longest streak', () => { - render(); - expect(screen.getByTestId('longest-streak')).toHaveTextContent( - 'profile.longest-streak' - ); - }); - - it('calculates the correct current streak', () => { - render(); - expect(screen.getByTestId('current-streak')).toHaveTextContent( - 'profile.current-streak' - ); - }); }); diff --git a/client/src/components/profile/components/heat-map.tsx b/client/src/components/profile/components/heat-map.tsx index bf6c799a95b..e6c1da4633e 100644 --- a/client/src/components/profile/components/heat-map.tsx +++ b/client/src/components/profile/components/heat-map.tsx @@ -43,8 +43,6 @@ interface CalendarData { interface HeatMapInnerProps { calendarData: CalendarData[]; - currentStreak: number; - longestStreak: number; pages: PageData[]; points?: number; t: TFunction; @@ -85,7 +83,7 @@ class HeatMapInner extends Component { } render() { - const { calendarData, currentStreak, longestStreak, pages, t } = this.props; + const { calendarData, pages, t } = this.props; const { startOfCalendar, endOfCalendar } = pages[this.state.pageIndex]; const title = `${startOfCalendar.toLocaleDateString([localeCode, 'en-US'], { year: 'numeric', @@ -164,18 +162,6 @@ class HeatMapInner extends Component { values={dataToDisplay} /> - - - -
- - {t('profile.longest-streak')} {longestStreak || 0} - - - {t('profile.current-streak')} {currentStreak || 0} - -
-

); @@ -188,7 +174,7 @@ const HeatMap = (props: HeatMapProps): JSX.Element => { /** * the following logic creates the data for the heatmap - * from the users calendar and calculates their streaks + * from the users calendar */ // create array of timestamps and turn into milliseconds @@ -232,11 +218,7 @@ const HeatMap = (props: HeatMapProps): JSX.Element => { dayCounter = addDays(dayCounter, 1); } - let longestStreak = 0; - let currentStreak = 0; - let lastIndex = -1; - - // add a point to each day with a completed timestamp and calculate streaks + // add a point to each day with a completed timestamp timestamps.forEach(stamp => { const index = calendarData.findIndex(day => isEqual(day.date, startOfDay(stamp)) @@ -245,42 +227,10 @@ const HeatMap = (props: HeatMapProps): JSX.Element => { if (index >= 0) { // add one point for today calendarData[index].count++; - - // if timestamp is on a new day, deal with streaks - if (index !== lastIndex) { - // if yesterday has points - if (calendarData[index - 1] && calendarData[index - 1].count > 0) { - currentStreak++; - } else { - currentStreak = 1; - } - - if (currentStreak > longestStreak) { - longestStreak = currentStreak; - } - } - - lastIndex = index; } }); - // if today has no points - if ( - calendarData[calendarData.length - 1] && - calendarData[calendarData.length - 1].count === 0 - ) { - currentStreak = 0; - } - - return ( - - ); + return ; }; HeatMap.displayName = 'HeatMap'; diff --git a/client/src/components/profile/components/heatmap.css b/client/src/components/profile/components/heatmap.css index 9ebfbe96e69..0254d3fe95a 100644 --- a/client/src/components/profile/components/heatmap.css +++ b/client/src/components/profile/components/heatmap.css @@ -1,11 +1,3 @@ -.streak-container { - display: flex; - justify-content: space-around; - align-items: center; - font-size: 18px; - color: var(--primary-color); -} - .heatmap-nav { text-align: center; } diff --git a/client/src/components/profile/components/stats.css b/client/src/components/profile/components/stats.css new file mode 100644 index 00000000000..8b25daca31a --- /dev/null +++ b/client/src/components/profile/components/stats.css @@ -0,0 +1,22 @@ +.stats { + display: flex; + justify-content: space-between; + align-items: center; + text-align: center; + color: var(--primary-color); +} + +.stats dt { + font-size: 18px; +} + +.stats dd { + font-size: 2rem; + margin-top: 16px; +} + +@media (max-width: 600px) { + .stats dd { + font-size: 1.5rem; + } +} diff --git a/client/src/components/profile/components/stats.test.tsx b/client/src/components/profile/components/stats.test.tsx new file mode 100644 index 00000000000..8c01721c4f4 --- /dev/null +++ b/client/src/components/profile/components/stats.test.tsx @@ -0,0 +1,24 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import Stats from './stats'; + +const props: { calendar: { [key: number]: number }; points: number } = { + calendar: {}, + points: 0 +}; + +describe('', () => { + it('calculates the correct longest streak', () => { + render(); + expect(screen.getByTestId('longest-streak')).toHaveTextContent( + 'profile.longest-streak' + ); + }); + + it('calculates the correct current streak', () => { + render(); + expect(screen.getByTestId('current-streak')).toHaveTextContent( + 'profile.current-streak' + ); + }); +}); diff --git a/client/src/components/profile/components/stats.tsx b/client/src/components/profile/components/stats.tsx new file mode 100644 index 00000000000..bb1b3923449 --- /dev/null +++ b/client/src/components/profile/components/stats.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import addDays from 'date-fns/addDays'; +import addMonths from 'date-fns/addMonths'; +import isEqual from 'date-fns/isEqual'; +import startOfDay from 'date-fns/startOfDay'; +import { User } from '../../../redux/prop-types'; +import { FullWidthRow, Spacer } from '../../helpers'; +import './stats.css'; + +interface StatsProps { + points: number; + calendar: User['calendar']; +} + +function Stats({ points, calendar }: StatsProps): JSX.Element { + const { t } = useTranslation(); + + /** + * the following logic calculates streaks from the + * users calendar + */ + + interface PageData { + startOfCalendar: Date; + endOfCalendar: Date; + } + + interface CalendarData { + date: Date; + count: number; + } + + // create array of timestamps and turn into milliseconds + const timestamps = Object.keys(calendar).map( + stamp => Number.parseInt(stamp, 10) * 1000 + ); + const startOfTimestamps = startOfDay(new Date(timestamps[0])); + let endOfCalendar = startOfDay(Date.now()); + let startOfCalendar; + + const pages: PageData[] = []; + + do { + startOfCalendar = addDays(addMonths(endOfCalendar, -6), 1); + + const newPage = { + startOfCalendar: startOfCalendar, + endOfCalendar: endOfCalendar + }; + + pages.push(newPage); + + endOfCalendar = addDays(startOfCalendar, -1); + } while (startOfTimestamps < startOfCalendar); + + pages.reverse(); + + const calendarData: CalendarData[] = []; + let dayCounter = pages[0].startOfCalendar; + + // create an object for each day of the calendar period + while (dayCounter <= pages[pages.length - 1].endOfCalendar) { + const newDay = { + date: startOfDay(dayCounter), + count: 0 + }; + + calendarData.push(newDay); + dayCounter = addDays(dayCounter, 1); + } + + let longestStreak = 0; + let currentStreak = 0; + let lastIndex = -1; + + // add a point to each day with a completed timestamp and calculate streaks + timestamps.forEach(stamp => { + const index = calendarData.findIndex(day => + isEqual(day.date, startOfDay(stamp)) + ); + + if (index >= 0) { + // add one point for today + calendarData[index].count++; + + // if timestamp is on a new day, deal with streaks + if (index !== lastIndex) { + // if yesterday has points + if (calendarData[index - 1] && calendarData[index - 1].count > 0) { + currentStreak++; + } else { + currentStreak = 1; + } + + if (currentStreak > longestStreak) { + longestStreak = currentStreak; + } + } + + lastIndex = index; + } + }); + + // if today has no points + if ( + calendarData[calendarData.length - 1] && + calendarData[calendarData.length - 1].count === 0 + ) { + currentStreak = 0; + } + + return ( + +

Stats

+ +
+
+
+ {t('profile.current-streak')} +
+
{currentStreak || 0}
+
+
+
+ {t('profile.total-points')} +
+
{points}
+
+
+
+ {t('profile.longest-streak')} +
+
{longestStreak || 0}
+
+
+
+
+ ); +} + +export default Stats; diff --git a/client/src/components/profile/profile.tsx b/client/src/components/profile/profile.tsx index dadc2c25619..46ab416d4aa 100644 --- a/client/src/components/profile/profile.tsx +++ b/client/src/components/profile/profile.tsx @@ -9,6 +9,7 @@ import { User } from './../../redux/prop-types'; import Timeline from './components/time-line'; import Camper from './components/camper'; import Certifications from './components/certifications'; +import Stats from './components/stats'; import HeatMap from './components/heat-map'; import { PortfolioProjects } from './components/portfolio-projects'; @@ -56,13 +57,7 @@ const Message = ({ isSessionUser, t, username }: MessageProps) => { return ; }; -function UserProfile({ - user, - t -}: { - user: ProfileProps['user']; - t: TFunction; -}): JSX.Element { +function UserProfile({ user }: { user: ProfileProps['user'] }): JSX.Element { const { profileUI: { showAbout, @@ -92,6 +87,7 @@ function UserProfile({ yearsTopContributor, isDonating } = user; + return ( <> - {showPoints && ( -

- {t('profile.total-points', { count: points })} -

- )} + {showPoints ? : null} {showHeatMap ? : null} {showCerts ? : null} {showPortfolio ? ( @@ -146,7 +138,7 @@ function Profile({ user, isSessionUser }: ProfileProps): JSX.Element { {isLocked && ( )} - {showUserProfile && } + {showUserProfile && } {!isSessionUser && ( diff --git a/e2e/profile.spec.ts b/e2e/profile.spec.ts index bcf73e1dd8f..4fff58fb902 100644 --- a/e2e/profile.spec.ts +++ b/e2e/profile.spec.ts @@ -116,7 +116,7 @@ test.describe('Profile component', () => { }); test('renders total points correctly', async ({ page }) => { - await expect(page.getByText('Number of points: 1')).toBeVisible(); + await expect(page.getByText('Total Points:')).toBeVisible(); }); // The date range computation in this test doesn't match the implementation code,