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

feat: added stats component to profile page #54301

Merged
merged 4 commits into from Apr 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion client/i18n/locales/english/translations.json
Expand Up @@ -308,7 +308,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}}"
Expand Down
14 changes: 0 additions & 14 deletions client/src/components/profile/components/heat-map.test.tsx
Expand Up @@ -65,18 +65,4 @@ describe('<HeatMap/>', () => {
screen.getByText(`${startOfCalendar} - ${endOfCalendar}`)
).toBeInTheDocument();
});

it('calculates the correct longest streak', () => {
render(<HeatMap {...props} />);
expect(screen.getByTestId('longest-streak')).toHaveTextContent(
'profile.longest-streak'
);
});

it('calculates the correct current streak', () => {
render(<HeatMap {...props} />);
expect(screen.getByTestId('current-streak')).toHaveTextContent(
'profile.current-streak'
);
});
});
58 changes: 4 additions & 54 deletions client/src/components/profile/components/heat-map.tsx
Expand Up @@ -43,8 +43,6 @@ interface CalendarData {

interface HeatMapInnerProps {
calendarData: CalendarData[];
currentStreak: number;
longestStreak: number;
pages: PageData[];
points?: number;
t: TFunction;
Expand Down Expand Up @@ -85,7 +83,7 @@ class HeatMapInner extends Component<HeatMapInnerProps, HeatMapInnerState> {
}

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',
Expand Down Expand Up @@ -164,18 +162,6 @@ class HeatMapInner extends Component<HeatMapInnerProps, HeatMapInnerState> {
values={dataToDisplay}
/>
<ReactTooltip className='react-tooltip' effect='solid' html={true} />

<Spacer size='medium' />
<Row>
<div className='streak-container'>
<span className='streak' data-testid='longest-streak'>
<b>{t('profile.longest-streak')}</b> {longestStreak || 0}
</span>
<span className='streak' data-testid='current-streak'>
<b>{t('profile.current-streak')}</b> {currentStreak || 0}
</span>
</div>
</Row>
<hr />
</FullWidthRow>
);
Expand All @@ -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
Expand Down Expand Up @@ -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))
Expand All @@ -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 (
<HeatMapInner
calendarData={calendarData}
currentStreak={currentStreak}
longestStreak={longestStreak}
pages={pages}
t={t}
/>
);
return <HeatMapInner calendarData={calendarData} pages={pages} t={t} />;
};

HeatMap.displayName = 'HeatMap';
Expand Down
8 changes: 0 additions & 8 deletions 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;
}
Expand Down
22 changes: 22 additions & 0 deletions 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;
}
}
24 changes: 24 additions & 0 deletions 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('<Stats/>', () => {
it('calculates the correct longest streak', () => {
render(<Stats {...props} />);
expect(screen.getByTestId('longest-streak')).toHaveTextContent(
'profile.longest-streak'
);
});

it('calculates the correct current streak', () => {
ahmaxed marked this conversation as resolved.
Show resolved Hide resolved
render(<Stats {...props} />);
expect(screen.getByTestId('current-streak')).toHaveTextContent(
'profile.current-streak'
);
});
});
142 changes: 142 additions & 0 deletions 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 (
<FullWidthRow>
<h2 className='text-center'>Stats</h2>
<Spacer size='small' />
<dl className='stats'>
<div>
<dt>
<b data-testid='current-streak'>{t('profile.current-streak')}</b>
</dt>
<dd>{currentStreak || 0}</dd>
</div>
<div>
<dt>
<b>{t('profile.total-points')}</b>
</dt>
<dd>{points}</dd>
</div>
<div>
<dt>
<b data-testid='longest-streak'>{t('profile.longest-streak')}</b>
</dt>
<dd>{longestStreak || 0}</dd>
</div>
</dl>
<hr />
</FullWidthRow>
);
}

export default Stats;
18 changes: 5 additions & 13 deletions client/src/components/profile/profile.tsx
Expand Up @@ -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';

Expand Down Expand Up @@ -56,13 +57,7 @@ const Message = ({ isSessionUser, t, username }: MessageProps) => {
return <VisitorMessage t={t} username={username} />;
};

function UserProfile({
user,
t
}: {
user: ProfileProps['user'];
t: TFunction;
}): JSX.Element {
function UserProfile({ user }: { user: ProfileProps['user'] }): JSX.Element {
const {
profileUI: {
showAbout,
Expand Down Expand Up @@ -92,6 +87,7 @@ function UserProfile({
yearsTopContributor,
isDonating
} = user;

return (
<>
<Camper
Expand All @@ -108,11 +104,7 @@ function UserProfile({
website={website}
yearsTopContributor={yearsTopContributor}
/>
{showPoints && (
<p className='text-center points'>
{t('profile.total-points', { count: points })}
</p>
)}
{showPoints ? <Stats points={points} calendar={calendar} /> : null}
{showHeatMap ? <HeatMap calendar={calendar} /> : null}
{showCerts ? <Certifications username={username} /> : null}
{showPortfolio ? (
Expand Down Expand Up @@ -146,7 +138,7 @@ function Profile({ user, isSessionUser }: ProfileProps): JSX.Element {
{isLocked && (
<Message username={username} isSessionUser={isSessionUser} t={t} />
)}
{showUserProfile && <UserProfile user={user} t={t} />}
{showUserProfile && <UserProfile user={user} />}
{!isSessionUser && (
<Row className='text-center'>
<Link to={`/user/${username}/report-user`}>
Expand Down
2 changes: 1 addition & 1 deletion e2e/profile.spec.ts
Expand Up @@ -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,
Expand Down