diff --git a/client/jest-timezone-setup.js b/client/jest-timezone-setup.js deleted file mode 100644 index 6e1fbf41d0..0000000000 --- a/client/jest-timezone-setup.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = async () => { - process.env.TZ = 'UTC'; -}; diff --git a/client/jest.config.js b/client/jest.config.js index 0922809dd1..86cabeba97 100644 --- a/client/jest.config.js +++ b/client/jest.config.js @@ -12,7 +12,6 @@ module.exports = { globals: { __PATH_PREFIX__: '' }, - globalSetup: './jest-timezone-setup.js', verbose: true, transform: { '^.+\\.js$': '/jest.transform.js' diff --git a/client/jest.test.js b/client/jest.test.js deleted file mode 100644 index 9819289191..0000000000 --- a/client/jest.test.js +++ /dev/null @@ -1,6 +0,0 @@ -/* global expect */ -describe('Timezones', () => { - it('should always be UTC', () => { - expect(new Date().getTimezoneOffset()).toBe(0); - }); -}); diff --git a/client/package-lock.json b/client/package-lock.json index 98ebd1b88d..6347422934 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -2918,6 +2918,40 @@ } } }, + "@freecodecamp/react-calendar-heatmap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@freecodecamp/react-calendar-heatmap/-/react-calendar-heatmap-1.0.0.tgz", + "integrity": "sha512-+bqI/VEVHiuvD+Ca17e9os4eQ8MG5xv/tXjyWYjK5zfo81FiCPF10P3LbAkHnttRatxxeudTDCmJjCR2kSM0xQ==", + "requires": { + "memoize-one": "^5.0.0", + "prop-types": "^15.6.2" + }, + "dependencies": { + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "prop-types": { + "version": "15.7.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", + "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.8.1" + } + }, + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + } + } + }, "@hapi/address": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-2.1.2.tgz", @@ -19748,9 +19782,9 @@ } }, "memoize-one": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.0.5.tgz", - "integrity": "sha512-ey6EpYv0tEaIbM/nTDOpHciXUvd+ackQrJgEzBwemhZZIWZjcyodqEcrmqDy2BKRTM3a65kKBV4WtLXJDt26SQ==" + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.1.1.tgz", + "integrity": "sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA==" }, "memory-fs": { "version": "0.4.1", @@ -22348,40 +22382,6 @@ } } }, - "react-calendar-heatmap": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/react-calendar-heatmap/-/react-calendar-heatmap-1.8.1.tgz", - "integrity": "sha512-4Hbq/pDMJoCPzZnyIWFfHgokLlLXzKyGsDcMgNhYpi7zcKHcvsK9soLEPvhW2dBBqgDrQOSp/uG4wtifaDg4eQ==", - "requires": { - "memoize-one": "^5.0.0", - "prop-types": "^15.6.2" - }, - "dependencies": { - "loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "requires": { - "js-tokens": "^3.0.0 || ^4.0.0" - } - }, - "prop-types": { - "version": "15.7.2", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", - "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", - "requires": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.8.1" - } - }, - "react-is": { - "version": "16.9.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.9.0.tgz", - "integrity": "sha512-tJBzzzIgnnRfEm046qRcURvwQnZVXmuCbscxUO5RWrGTXpon2d4c8mI0D8WE6ydVIm29JiLB6+RslkIvym9Rjw==" - } - } - }, "react-dev-utils": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-4.2.3.tgz", diff --git a/client/package.json b/client/package.json index fd526fa26d..43476d8350 100644 --- a/client/package.json +++ b/client/package.json @@ -16,6 +16,7 @@ "@fortawesome/react-fontawesome": "^0.1.4", "@freecodecamp/loop-protect": "^2.2.1", "@freecodecamp/react-bootstrap": "^0.32.3", + "@freecodecamp/react-calendar-heatmap": "^1.0.0", "@reach/router": "^1.2.1", "algoliasearch": "^3.35.1", "axios": "^0.19.0", @@ -48,7 +49,6 @@ "prismjs": "^1.17.1", "query-string": "^6.8.3", "react": "^16.10.2", - "react-calendar-heatmap": "^1.8.1", "react-dom": "^16.10.2", "react-final-form": "^6.3.0", "react-ga": "^2.7.0", diff --git a/client/src/components/profile/Profile.js b/client/src/components/profile/Profile.js index 852bc56461..ff89973bf6 100644 --- a/client/src/components/profile/Profile.js +++ b/client/src/components/profile/Profile.js @@ -29,10 +29,6 @@ const propTypes = { showTimeLine: PropTypes.bool }), calendar: PropTypes.object, - streak: PropTypes.shape({ - current: PropTypes.number, - longest: PropTypes.number - }), completedChallenges: PropTypes.array, portfolio: PropTypes.array, about: PropTypes.string, @@ -108,7 +104,6 @@ function renderProfile(user) { }, calendar, completedChallenges, - streak, githubProfile, isLinkedIn, isGithub, @@ -150,7 +145,7 @@ function renderProfile(user) { website={website} yearsTopContributor={yearsTopContributor} /> - {showHeatMap ? : null} + {showHeatMap ? : null} {showCerts ? : null} {showPortfolio ? : null} {showTimeLine ? ( diff --git a/client/src/components/profile/components/Camper.js b/client/src/components/profile/components/Camper.js index 7c361631e2..a2d1cff4be 100644 --- a/client/src/components/profile/components/Camper.js +++ b/client/src/components/profile/components/Camper.js @@ -110,11 +110,6 @@ function Camper({

)} {about &&

{about}

} - {typeof points === 'number' ? ( -

- {`${points} ${pluralise('point', points !== 1)}`} -

- ) : null} {yearsTopContributor.filter(Boolean).length > 0 && (

@@ -125,6 +120,11 @@ function Camper({
)}
+ {typeof points === 'number' ? ( +

+ {`${points} ${pluralise('total point', points !== 1)}`} +

+ ) : null} ); } diff --git a/client/src/components/profile/components/HeatMap.js b/client/src/components/profile/components/HeatMap.js index 166b82f074..6a431f348b 100644 --- a/client/src/components/profile/components/HeatMap.js +++ b/client/src/components/profile/components/HeatMap.js @@ -1,6 +1,7 @@ -import React from 'react'; +import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import CalendarHeatMap from 'react-calendar-heatmap'; +import CalendarHeatMap from '@freecodecamp/react-calendar-heatmap'; +import { Row } from '@freecodecamp/react-bootstrap'; import ReactTooltip from 'react-tooltip'; import addDays from 'date-fns/add_days'; import addMonths from 'date-fns/add_months'; @@ -10,52 +11,92 @@ import isEqual from 'date-fns/is_equal'; import FullWidthRow from '../../helpers/FullWidthRow'; import Spacer from '../../helpers/Spacer'; -import 'react-calendar-heatmap/dist/styles.css'; +import '@freecodecamp/react-calendar-heatmap/dist/styles.css'; import './heatmap.css'; const propTypes = { - calendar: PropTypes.object, - streak: PropTypes.shape({ - current: PropTypes.number, - longest: PropTypes.number - }) + calendar: PropTypes.object }; -function HeatMap({ calendar, streak }) { - const endOfCalendar = startOfDay(Date.now()); - const startOfCalendar = addMonths(endOfCalendar, -6); +const innerPropTypes = { + calendarData: PropTypes.array, + currentStreak: PropTypes.number, + longestStreak: PropTypes.number, + pages: PropTypes.array, + points: PropTypes.number +}; - let calendarData = []; - let dayCounter = startOfCalendar; +class HeatMapInner extends Component { + constructor(props) { + super(props); - // create a data point for each day of the calendar period (six months) - while (dayCounter <= endOfCalendar) { - // this is the format needed for react-calendar-heatmap - const newDay = { - date: startOfDay(dayCounter), - count: 0 + this.state = { + pageIndex: this.props.pages.length - 1 }; - calendarData.push(newDay); - dayCounter = addDays(dayCounter, 1); + this.prevPage = this.prevPage.bind(this); + this.nextPage = this.nextPage.bind(this); } - for (let timestamp of Object.keys(calendar)) { - timestamp = Number(timestamp * 1000) || null; - if (timestamp) { - const index = calendarData.findIndex(day => - isEqual(day.date, startOfDay(timestamp)) - ); - - if (index >= 0) { - calendarData[index].count++; - } - } + prevPage() { + this.setState( + { + pageIndex: this.state.pageIndex - 1 + }, + () => ReactTooltip.rebuild() + ); } - return ( - + nextPage() { + this.setState( + { + pageIndex: this.state.pageIndex + 1 + }, + () => ReactTooltip.rebuild() + ); + } + + render() { + const { calendarData, currentStreak, longestStreak, pages } = this.props; + const { startOfCalendar, endOfCalendar } = pages[this.state.pageIndex]; + const title = `${startOfCalendar.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short' + })} - ${endOfCalendar.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short' + })}`; + const dataToDisplay = calendarData.filter( + data => data.date >= startOfCalendar && data.date <= endOfCalendar + ); + + return ( + + + {title} + + + + { if (!value || value.count < 1) return 'color-empty'; @@ -87,27 +128,126 @@ function HeatMap({ calendar, streak }) { 'data-tip': `${valueCount} ${dateFormatted}` }; }} - values={calendarData} + values={dataToDisplay} /> + + + +
+ + Longest Streak: {longestStreak || 0} + + + Current Streak: {currentStreak || 0} + +
+
+
- - -
- - Longest Streak: {streak.longest || 0} - - - Current Streak: {streak.current || 0} - -
-
- -
-
- ); + ); + } } +HeatMapInner.propTypes = innerPropTypes; + +const HeatMap = props => { + const { calendar } = props; + + /** + * the following logic creates the data for the heatmap + * from the users calendar and calculates their streaks + */ + + // create array of timestamps and turn into milliseconds + const timestamps = Object.keys(calendar).map(stamp => stamp * 1000); + const startOfTimestamps = startOfDay(new Date(timestamps[0])); + let endOfCalendar = startOfDay(Date.now()); + let startOfCalendar; + + // creates pages for heatmap + let pages = []; + + 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(); + + let calendarData = []; + let dayCounter = pages[0].startOfCalendar; + + // create an object for each day of the calendar period + while (dayCounter <= pages[pages.length - 1].endOfCalendar) { + // this is the format needed for react-calendar-heatmap + 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 ( + + ); +}; + HeatMap.displayName = 'HeatMap'; HeatMap.propTypes = propTypes; diff --git a/client/src/components/profile/components/HeatMap.test.js b/client/src/components/profile/components/HeatMap.test.js index 2ccf488403..4f445937af 100644 --- a/client/src/components/profile/components/HeatMap.test.js +++ b/client/src/components/profile/components/HeatMap.test.js @@ -6,23 +6,27 @@ import { render } from '@testing-library/react'; import HeatMap from './HeatMap'; +// offset is used to shift the dates so that the calendar renders (for testing +// purposes only) the same way in each timezone. +const offset = new Date().getTimezoneOffset() * 60; +const date1 = 1580497504 + offset; +const date2 = 1580597504 + offset; +const date3 = 1580729769 + offset; + const props = { - calendar: { - 1580393017: 1, - 1580397504: 1 - }, - streak: { - current: 2, - longest: 2 - } + calendar: {} }; +props.calendar[date1] = 1; +props.calendar[date2] = 1; +props.calendar[date3] = 1; + let dateNowMockFn; beforeEach(() => { dateNowMockFn = jest .spyOn(Date, 'now') - .mockImplementation(() => 1580729769714); + .mockImplementation(() => 1580729769714 + offset * 1000); }); afterEach(() => { @@ -34,4 +38,18 @@ describe('', () => { const { container } = render(); expect(container).toMatchSnapshot(); }); + + it('calculates the correct longest streak', () => { + const { getByTestId } = render(); + expect(getByTestId('longest-streak').textContent).toContain( + 'Longest Streak: 2' + ); + }); + + it('calculates the correct current streak', () => { + const { getByTestId } = render(); + expect(getByTestId('current-streak').textContent).toContain( + 'Current Streak: 1' + ); + }); }); diff --git a/client/src/components/profile/components/__snapshots__/HeatMap.test.js.snap b/client/src/components/profile/components/__snapshots__/HeatMap.test.js.snap index af7e0b2ce3..f5e5e37b34 100644 --- a/client/src/components/profile/components/__snapshots__/HeatMap.test.js.snap +++ b/client/src/components/profile/components/__snapshots__/HeatMap.test.js.snap @@ -9,2275 +9,2286 @@ exports[` renders correctly 1`] = ` class="col-sm-8 col-sm-offset-2 col-xs-12" >
-
+ + + + Sep + + + Oct + + + Nov + + + Dec + + + Jan + + + Feb + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
-
- - - Longest Streak: - - - 2 - - - - Current Streak: - - - 2 - -
+ + Longest Streak: + + + 2 + + + + Current Streak: + + + 1 +
-

diff --git a/client/src/components/profile/components/heatmap.css b/client/src/components/profile/components/heatmap.css index ba5c936e42..9ebfbe96e6 100644 --- a/client/src/components/profile/components/heatmap.css +++ b/client/src/components/profile/components/heatmap.css @@ -6,8 +6,16 @@ color: var(--primary-color); } +.heatmap-nav { + text-align: center; +} + +.heatmap-nav-btn { + margin: 0 20px; +} + .react-calendar-heatmap-month-label { - color: var(--primary-color); + fill: var(--gray-45) !important; } .react-calendar-heatmap .color-empty {