Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Heatmap #9

Merged
merged 9 commits into from
Jun 23, 2020
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions global-setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = async () => {
process.env.TZ = 'America/New_York';
};
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"test": "react-scripts test --env=jest-environment-jsdom-sixteen",
"eject": "react-scripts eject",
"lint": "eslint .",
"format": "eslint --fix .",
Expand All @@ -30,6 +30,9 @@
"pre-commit": "sh scripts/validate-branch-name"
}
},
"jest": {
"globalSetup": "./global-setup.js"
},
"browserslist": {
"production": [
">0.2%",
Expand All @@ -54,6 +57,7 @@
"eslint-plugin-react-hooks": "^2.5.0",
"history": "^4.10.1",
"husky": "^4.2.5",
"jest-environment-jsdom-sixteen": "^1.0.3",
"react-test-renderer": "^16.13.1"
}
}
137 changes: 135 additions & 2 deletions src/components/Heatmap.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,138 @@
import React from 'react';
import React, { useState } from 'react';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file looks very simple compared to other solutions I've seen. Good job 👍

import PropTypes from 'prop-types';
import styled from 'styled-components';
import HeatmapCell from './HeatmapCell';

const daysOfWeek = [
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',
];

const hoursOfDay = ['12', '2', '4', '6', '8', '10'];

const createTimeHeader = (period) => (
hoursOfDay.map((hour) => {
const timeLabel = `${hour}:00${period}`;
return (
<TableHeader colSpan="2" key={timeLabel}>
{timeLabel}
</TableHeader>
);
})
);

const getCounts = (posts) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This name is very generic. Can you find a more descriptive name? Remember the email lesson about variable/function naming and the 5 Whats.

This also goes for the counts array below

const counts = Array(7).fill().map(() => Array(24).fill(0));

posts.forEach((post) => {
const date = new Date(post.data.created_utc * 1000);
counts[date.getDay()][date.getHours()] += 1;
});

return counts;
};

const Wrapper = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
margin-top: 60px;
`;

const Table = styled.table`
border-spacing: 0;
cell-collapse: 0;
`;

const TableHeader = styled.th`
background-image: linear-gradient(
${({ theme }) => theme.color.topgradient},
${({ theme }) => theme.color.bottomgradient}
);
border: none;
height: 52px;
padding: 0;
color: ${({ theme }) => theme.color.tablecolumnheader};
font-weight: 500;
font-size: ${({ theme }) => theme.font.size.small};
`;

const RowHeader = styled.td`
color: ${({ theme }) => theme.color.background};
background-color: ${({ theme }) => theme.color.tablerowheader};
text-align: center;
width: 154px;
height: 40px;
padding: 0;
font-weight: 600;
font-size: ${({ theme }) => theme.font.size.medium};
`;

const TimezoneWrapper = styled.div`
margin-top: 12px;
font-size: ${({ theme }) => theme.font.size.small};
`;

const Timezone = styled.span`
font-weight: bold;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

indentation

`;

const Heatmap = ({ posts }) => {
const [counts] = useState(getCounts(posts));
const [clickedCellId, setClickedCellId] = useState('');

const handleCellClick = (cellId) => {
setClickedCellId(cellId);
};

return (
<Wrapper>
<Table>
<thead>
<tr>
<th>&nbsp;</th>
{createTimeHeader('am')}
{createTimeHeader('pm')}
</tr>
</thead>
<tbody>
{counts.map((days, day) => {
const dayOfWeek = daysOfWeek[day];
return (
<tr key={dayOfWeek}>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could extract this into its own component. This would potentially allow you to apply performance optimizations with React.memo.

That's a bit premature optimization though. So no problem if you keep it like it is. I just wanted you to know about React.memo 😄

<RowHeader key={dayOfWeek}>{dayOfWeek}</RowHeader>
{days.map((hourCount, hour) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

indentation

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is okay? Since days.map... isn't part of RowHeader? I separated out RowHeader into three lines to make it clearer.

const id = `${dayOfWeek}-${hour}`;
const colorValue = Math.min(hourCount, 10);
return (
<HeatmapCell
colorValue={colorValue}
key={id}
id={id}
handleCellClick={handleCellClick}
clickedCellId={clickedCellId}
>
{hourCount}
</HeatmapCell>
);
})}
</tr>
);
})}
</tbody>
</Table>
<TimezoneWrapper>
All times are shown in your timezone:
{' '}
<Timezone>{Intl.DateTimeFormat().resolvedOptions().timeZone}</Timezone>
</TimezoneWrapper>
</Wrapper>
);
};

Heatmap.propTypes = {
posts: PropTypes.arrayOf(PropTypes.object).isRequired,
};

const Heatmap = () => <div>Heatmap Placeholder</div>;

export default Heatmap;
59 changes: 59 additions & 0 deletions src/components/HeatmapCell.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';

const TableCell = styled.td`
text-align: center;
width: 39px;
height: 39px;
padding: 0;
color: white;
font-weight: bold;
font-size: ${({ theme }) => theme.font.size.small};
background-color: ${({ theme, colorValue }) => theme.color.count[colorValue]};
border: 1px solid ${({ theme, colorValue, clicked }) => (
clicked ? theme.color.tablerowheader : theme.color.count[colorValue]
)};
&:hover {
border: 1px solid ${({ theme }) => theme.color.tablerowheader};
}
`;

const HeatmapCell = ({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a small suggestion 😄 You could make this component shorter by removing the const.

const HeatmapCell = ({
children, colorValue, handleCellClick, id, clickedCellId,
}) => (
    <TableCell
      colorValue={colorValue}
      clicked={clickedCellId === id}
      onClick={() => handleCellClick(id)}
    >
      {children}
    </TableCell>
  );

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love these kinds of suggestions. I think I get caught up in the tiny details sometimes and I forget to step back and see where I can make things more efficient.

children, colorValue, handleCellClick, id, clickedCellId,
}) => {
const [clicked, setClicked] = useState(false);

const handleClick = () => {
setClicked(true);
handleCellClick(id);
};

useEffect(() => {
if (clickedCellId === id) {
setClicked(true);
} else {
setClicked(false);
}
}, [clickedCellId, id]);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a very common mistake and you might be surprised how much simpler it can be 😄

clicked is just derived from the clickedCellId and id props. So you can simply replace the state variable by const clicked = clickedCellId === id;as you did in theuseEffect`. Try it out, your component will look much simpler

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

woah! So much better. Thanks! I was overthinking it, it seems


return (
<TableCell
colorValue={colorValue}
clicked={clicked}
onClick={handleClick}
>
{children}
</TableCell>
);
};

HeatmapCell.propTypes = {
children: PropTypes.node.isRequired,
colorValue: PropTypes.number.isRequired,
handleCellClick: PropTypes.func.isRequired,
id: PropTypes.string.isRequired,
clickedCellId: PropTypes.string.isRequired,
};

export default HeatmapCell;
2 changes: 1 addition & 1 deletion src/components/PostTable.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
const PostTable = ({ posts }) => (
<>
<div>Data Table Placeholder</div>
{posts.slice(0, 10).map((post) => (
{posts.slice(0, 1).map((post) => (
<div key={post.data.name}>{post.data.title}</div>
))}
</>
Expand Down
2 changes: 1 addition & 1 deletion src/pages/Search.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const Search = () => {
}
return (
<>
<Heatmap />
<Heatmap posts={posts} />
<PostTable posts={posts} />
</>
);
Expand Down
18 changes: 18 additions & 0 deletions src/styles/themes.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,30 @@ const mainTheme = {
externalLink: '#0087ff',
searchBoxBorder: '#e6e6e6',
spinner: '#fdb755',
tablecolumnheader: '#787878',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd probably use camelCase here 😉

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oops... my python is showing 😅

tablerowheader: '#1E2537',
topgradient: '#fefefe',
bottomgradient: '#e9e9e9',
count: {
0: '#e0e592',
1: '#aed396',
2: '#a9d194',
3: '#a0ce93',
4: '#99cd94',
5: '#8cc894',
6: '#5eb391',
7: '#5db492',
8: '#5cb391',
9: '#5aad8c',
10: '#3984a3',
},
Comment on lines +46 to +58
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perfect 🚀

},
font: {
primary: "'Montserrat', san-serif",
secondary: "'Bitter', serif",
size: {
normal: '16px',
medium: '15px',
small: '14px',
},
},
Expand Down
61 changes: 61 additions & 0 deletions src/tests/Heatmap.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { render, fireEvent } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import fetchMock from 'jest-fetch-mock';
import App from '../App';
import * as kittens1 from './responses/1.json';
import * as kittens2 from './responses/2.json';
import * as kittens3 from './responses/3.json';
import * as kittens4 from './responses/4.json';
import * as kittens5 from './responses/5.json';

fetchMock.enableMocks();

const renderSearchPage = (route) => render(
<MemoryRouter initialEntries={[route]}>
<App />
</MemoryRouter>,
);

describe('Heatmap', () => {
beforeEach(() => {
fetch.resetMocks();

fetch
.once(JSON.stringify(kittens1))
.once(JSON.stringify(kittens2))
.once(JSON.stringify(kittens3))
.once(JSON.stringify(kittens4))
.once(JSON.stringify(kittens5));
});

it('to contain post counts for each hour of each day', async () => {
let tableCells;
await act(async () => {
const { findAllByRole } = renderSearchPage('/search/kittens');
tableCells = await findAllByRole('cell');
});

expect(tableCells.length).toEqual(175); // Includes row headers
expect(tableCells[1].innerHTML).toEqual('3'); // Sunday at 12:00am
});

it('cell highlights on click', async () => {
let tableCells;
await act(async () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not required. You can remove the act

const { findAllByRole } = renderSearchPage('/search/kittens');
tableCells = await findAllByRole('cell');
});

const originalStyle = window.getComputedStyle(tableCells[1]);
const originalBorder = originalStyle.getPropertyValue('border');
expect(originalBorder).toEqual('1px solid #a0ce93');

fireEvent.click(tableCells[1]);

const updatedStyle = window.getComputedStyle(tableCells[1]);
const updatedBorder = updatedStyle.getPropertyValue('border');
expect(updatedBorder).toEqual('1px solid #1e2537');
Comment on lines +48 to +56
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haven't seen anyone testing the borders yet! 🎉

});
});
Loading