mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-04-26 07:00:55 -04:00
feat: added stats component to profile page (#54301)
This commit is contained in:
committed by
GitHub
parent
c14cfeed5a
commit
5cbe0b709e
@@ -310,7 +310,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}}"
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,8 +43,6 @@ interface CalendarData {
|
||||
|
||||
interface HeatMapInnerProps {
|
||||
calendarData: CalendarData[];
|
||||
currentStreak: number;
|
||||
longestStreak: number;
|
||||
pages: PageData[];
|
||||
points?: number;
|
||||
t: TFunction;
|
||||
@@ -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',
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
22
client/src/components/profile/components/stats.css
Normal file
22
client/src/components/profile/components/stats.css
Normal file
@@ -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
client/src/components/profile/components/stats.test.tsx
Normal file
24
client/src/components/profile/components/stats.test.tsx
Normal file
@@ -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', () => {
|
||||
render(<Stats {...props} />);
|
||||
expect(screen.getByTestId('current-streak')).toHaveTextContent(
|
||||
'profile.current-streak'
|
||||
);
|
||||
});
|
||||
});
|
||||
142
client/src/components/profile/components/stats.tsx
Normal file
142
client/src/components/profile/components/stats.tsx
Normal file
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
@@ -92,6 +87,7 @@ function UserProfile({
|
||||
yearsTopContributor,
|
||||
isDonating
|
||||
} = user;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Camper
|
||||
@@ -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 ? (
|
||||
@@ -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`}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user