feat: paginate heatmap + calculate streaks on client (#38318)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Tom
2020-03-31 15:34:54 -05:00
committed by GitHub
parent 7e29462168
commit 0ffc657d5f
11 changed files with 2535 additions and 2373 deletions

View File

@ -1,3 +0,0 @@
module.exports = async () => {
process.env.TZ = 'UTC';
};

View File

@ -12,7 +12,6 @@ module.exports = {
globals: {
__PATH_PREFIX__: ''
},
globalSetup: './jest-timezone-setup.js',
verbose: true,
transform: {
'^.+\\.js$': '<rootDir>/jest.transform.js'

View File

@ -1,6 +0,0 @@
/* global expect */
describe('Timezones', () => {
it('should always be UTC', () => {
expect(new Date().getTimezoneOffset()).toBe(0);
});
});

View File

@ -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",

View File

@ -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",

View File

@ -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 ? <HeatMap calendar={calendar} streak={streak} /> : null}
{showHeatMap ? <HeatMap calendar={calendar} /> : null}
{showCerts ? <Certifications username={username} /> : null}
{showPortfolio ? <Portfolio portfolio={portfolio} /> : null}
{showTimeLine ? (

View File

@ -110,11 +110,6 @@ function Camper({
</p>
)}
{about && <p className='bio text-center'>{about}</p>}
{typeof points === 'number' ? (
<p className='text-center points'>
{`${points} ${pluralise('point', points !== 1)}`}
</p>
) : null}
{yearsTopContributor.filter(Boolean).length > 0 && (
<div>
<br />
@ -125,6 +120,11 @@ function Camper({
</div>
)}
<br />
{typeof points === 'number' ? (
<p className='text-center points'>
{`${points} ${pluralise('total point', points !== 1)}`}
</p>
) : null}
</div>
);
}

View File

@ -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);
let calendarData = [];
let dayCounter = startOfCalendar;
// 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
const innerPropTypes = {
calendarData: PropTypes.array,
currentStreak: PropTypes.number,
longestStreak: PropTypes.number,
pages: PropTypes.array,
points: PropTypes.number
};
calendarData.push(newDay);
dayCounter = addDays(dayCounter, 1);
class HeatMapInner extends Component {
constructor(props) {
super(props);
this.state = {
pageIndex: this.props.pages.length - 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))
prevPage() {
this.setState(
{
pageIndex: this.state.pageIndex - 1
},
() => ReactTooltip.rebuild()
);
}
if (index >= 0) {
calendarData[index].count++;
}
}
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 (
<FullWidthRow>
<FullWidthRow>
<Row className='heatmap-nav'>
<button
className='heatmap-nav-btn'
disabled={!pages[this.state.pageIndex - 1]}
onClick={this.prevPage}
style={{
visibility: pages[this.state.pageIndex - 1] ? 'unset' : 'hidden'
}}
>
&lt;
</button>
<span>{title}</span>
<button
className='heatmap-nav-btn'
disabled={!pages[this.state.pageIndex + 1]}
onClick={this.nextPage}
style={{
visibility: pages[this.state.pageIndex + 1] ? 'unset' : 'hidden'
}}
>
&gt;
</button>
</Row>
<Spacer />
<CalendarHeatMap
classForValue={value => {
if (!value || value.count < 1) return 'color-empty';
@ -87,26 +128,125 @@ function HeatMap({ calendar, streak }) {
'data-tip': `<b>${valueCount}</b> ${dateFormatted}`
};
}}
values={calendarData}
values={dataToDisplay}
/>
<ReactTooltip className='react-tooltip' effect='solid' html={true} />
</FullWidthRow>
<Spacer />
<FullWidthRow>
<Row>
<div className='streak-container'>
<span className='streak'>
<b>Longest Streak:</b> {streak.longest || 0}
<span className='streak' data-testid='longest-streak'>
<b>Longest Streak:</b> {longestStreak || 0}
</span>
<span className='streak'>
<b>Current Streak:</b> {streak.current || 0}
<span className='streak' data-testid='current-streak'>
<b>Current Streak:</b> {currentStreak || 0}
</span>
</div>
</FullWidthRow>
<Spacer />
</Row>
<hr />
</FullWidthRow>
);
}
}
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 (
<HeatMapInner
calendarData={calendarData}
currentStreak={currentStreak}
longestStreak={longestStreak}
pages={pages}
/>
);
};
HeatMap.displayName = 'HeatMap';
HeatMap.propTypes = propTypes;

View File

@ -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('<HeatMap/>', () => {
const { container } = render(<HeatMap {...props} />);
expect(container).toMatchSnapshot();
});
it('calculates the correct longest streak', () => {
const { getByTestId } = render(<HeatMap {...props} />);
expect(getByTestId('longest-streak').textContent).toContain(
'Longest Streak: 2'
);
});
it('calculates the correct current streak', () => {
const { getByTestId } = render(<HeatMap {...props} />);
expect(getByTestId('current-streak').textContent).toContain(
'Current Streak: 1'
);
});
});

View File

@ -9,11 +9,30 @@ exports[`<HeatMap/> renders correctly 1`] = `
class="col-sm-8 col-sm-offset-2 col-xs-12"
>
<div
class="row"
class="heatmap-nav row"
>
<button
class="heatmap-nav-btn"
disabled=""
style="visibility: hidden;"
>
&lt;
</button>
<span>
Aug 2019 - Feb 2020
</span>
<button
class="heatmap-nav-btn"
disabled=""
style="visibility: hidden;"
>
&gt;
</button>
</div>
<div
class="col-sm-8 col-sm-offset-2 col-xs-12"
>
class="spacer"
style="padding: 15px 0px; height: 1px;"
/>
<svg
class="react-calendar-heatmap"
viewBox="0 0 296 90"
@ -2168,9 +2187,9 @@ exports[`<HeatMap/> renders correctly 1`] = `
<title />
</rect>
<rect
class="color-scale-1"
class="color-empty"
currentItem="false"
data-tip="<b>2 points</b> on Jan 30, 2020"
data-tip="<b>No points</b> on Jan 30, 2020"
height="10"
width="10"
x="0"
@ -2179,9 +2198,9 @@ exports[`<HeatMap/> renders correctly 1`] = `
<title />
</rect>
<rect
class="color-empty"
class="color-scale-1"
currentItem="false"
data-tip="<b>No points</b> on Jan 31, 2020"
data-tip="<b>1 point</b> on Jan 31, 2020"
height="10"
width="10"
x="0"
@ -2190,9 +2209,9 @@ exports[`<HeatMap/> renders correctly 1`] = `
<title />
</rect>
<rect
class="color-empty"
class="color-scale-1"
currentItem="false"
data-tip="<b>No points</b> on Feb 1, 2020"
data-tip="<b>1 point</b> on Feb 1, 2020"
height="10"
width="10"
x="0"
@ -2217,9 +2236,9 @@ exports[`<HeatMap/> renders correctly 1`] = `
<title />
</rect>
<rect
class="color-empty"
class="color-scale-1"
currentItem="false"
data-tip="<b>No points</b> on Feb 3, 2020"
data-tip="<b>1 point</b> on Feb 3, 2020"
height="10"
width="10"
x="0"
@ -2238,23 +2257,19 @@ exports[`<HeatMap/> renders correctly 1`] = `
class="__react_component_tooltip place-top type-dark "
data-id="tooltip"
/>
</div>
</div>
<div
class="spacer"
style="padding: 15px 0px; height: 1px;"
/>
<div
class="row"
>
<div
class="col-sm-8 col-sm-offset-2 col-xs-12"
>
<div
class="streak-container"
>
<span
class="streak"
data-testid="longest-streak"
>
<b>
Longest Streak:
@ -2264,20 +2279,16 @@ exports[`<HeatMap/> renders correctly 1`] = `
</span>
<span
class="streak"
data-testid="current-streak"
>
<b>
Current Streak:
</b>
2
1
</span>
</div>
</div>
</div>
<div
class="spacer"
style="padding: 15px 0px; height: 1px;"
/>
<hr />
</div>
</div>

View File

@ -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 {