From 0ffc657d5fa045300fe2493053f541285507c773 Mon Sep 17 00:00:00 2001
From: Tom <20648924+moT01@users.noreply.github.com>
Date: Tue, 31 Mar 2020 15:34:54 -0500
Subject: [PATCH] feat: paginate heatmap + calculate streaks on client (#38318)
Co-authored-by: Oliver Eyton-Williams
---
client/jest-timezone-setup.js | 3 -
client/jest.config.js | 1 -
client/jest.test.js | 6 -
client/package-lock.json | 74 +-
client/package.json | 2 +-
client/src/components/profile/Profile.js | 7 +-
.../components/profile/components/Camper.js | 10 +-
.../components/profile/components/HeatMap.js | 240 +-
.../profile/components/HeatMap.test.js | 36 +-
.../__snapshots__/HeatMap.test.js.snap | 4519 +++++++++--------
.../components/profile/components/heatmap.css | 10 +-
11 files changed, 2535 insertions(+), 2373 deletions(-)
delete mode 100644 client/jest-timezone-setup.js
delete mode 100644 client/jest.test.js
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"
>
-
-
-
-
+ <
+
+
+ Aug 2019 - Feb 2020
+
+
+
+
+
-
-
-
- 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 {