Skip to content

Commit 09499ee

Browse files
fix(profile): simplify stats component and allow streaks to exceed 6 months (freeCodeCamp#58763)
Co-authored-by: Oliver Eyton-Williams <[email protected]>
1 parent 1d9e1f2 commit 09499ee

File tree

2 files changed

+123
-103
lines changed

2 files changed

+123
-103
lines changed

client/src/components/profile/components/stats.test.tsx

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { render, screen } from '@testing-library/react';
22
import React from 'react';
3-
import Stats from './stats';
3+
import Stats, { calculateStreaks } from './stats';
44

55
const props: { calendar: { [key: number]: number }; points: number } = {
66
calendar: {},
@@ -22,3 +22,85 @@ describe('<Stats/>', () => {
2222
);
2323
});
2424
});
25+
26+
const oldStreakCalendar = {
27+
'1736496000': 1, // 2025-01-10 08:00:00 UTC
28+
'1736582400': 1, // 2025-01-11 08:00:00 UTC
29+
'1736668800': 1, // 2025-01-12 08:00:00 UTC
30+
'1736755200': 1, // 2025-01-13 08:00:00 UTC
31+
'1736841600': 1 // 2025-01-14 08:00:00 UTC
32+
};
33+
34+
const recentStreakCalendar = {
35+
'1736699400': 1, // 2025-01-12 16:30:00 UTC
36+
'1736763300': 1, // 2025-01-13 10:15:00 UTC
37+
'1736865900': 1 // 2025-01-14 14:45:00 UTC
38+
};
39+
40+
const twoStreakCalendar = {
41+
'1736503200': 1, // 2025-01-10 10:00:00 UTC
42+
'1736604000': 1, // 2025-01-11 14:00:00 UTC
43+
'1736697600': 1, // 2025-01-12 16:00:00 UTC
44+
// Skipping Jan 13, 2025
45+
'1736845200': 1, // 2025-01-14 09:00:00 UTC
46+
'1736946000': 1 // 2025-01-15 13:00:00 UTC
47+
};
48+
49+
jest.useFakeTimers();
50+
51+
describe('calculateStreaks', () => {
52+
test('Should return a longest streak of 5 days when the user has not completed a challenge in a while', () => {
53+
jest.setSystemTime(new Date(2025, 0, 15));
54+
const { longestStreak, currentStreak } =
55+
calculateStreaks(oldStreakCalendar);
56+
57+
expect(longestStreak).toBe(5);
58+
expect(currentStreak).toBe(0);
59+
});
60+
61+
test('Should calculate longest streak, regardless of how long ago they were', () => {
62+
jest.setSystemTime(new Date(2030, 0, 15));
63+
const { longestStreak, currentStreak } =
64+
calculateStreaks(oldStreakCalendar);
65+
66+
expect(longestStreak).toBe(5);
67+
expect(currentStreak).toBe(0);
68+
});
69+
70+
test('Should return a longest streak of 3 days when the current streak is 3 days', () => {
71+
jest.setSystemTime(new Date(2025, 0, 14));
72+
const { longestStreak, currentStreak } =
73+
calculateStreaks(recentStreakCalendar);
74+
75+
expect(longestStreak).toBe(3);
76+
expect(currentStreak).toBe(3);
77+
});
78+
79+
test('Should return a longest and current streaks of 1 day when the user has recently completed their first challenge', () => {
80+
const now = new Date(2025, 0, 15);
81+
jest.setSystemTime(now);
82+
const calendar = {
83+
[now.valueOf() / 1000]: 1
84+
};
85+
86+
const { longestStreak, currentStreak } = calculateStreaks(calendar);
87+
88+
expect(longestStreak).toBe(1);
89+
expect(currentStreak).toBe(1);
90+
});
91+
92+
test('Should return a current streak of 2 days with a longest streak of 3 days when the longest streak is longer than the current one', () => {
93+
const { longestStreak, currentStreak } =
94+
calculateStreaks(twoStreakCalendar);
95+
96+
expect(longestStreak).toBe(3);
97+
expect(currentStreak).toBe(2);
98+
});
99+
100+
test('Should return a streak of 0 days if no challenges have been completed', () => {
101+
const { longestStreak, currentStreak } = calculateStreaks({});
102+
103+
expect(longestStreak).toBe(0);
104+
expect(currentStreak).toBe(0);
105+
});
106+
});

client/src/components/profile/components/stats.tsx

Lines changed: 40 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,123 +1,61 @@
1-
import React from 'react';
1+
import React, { useState, useEffect } from 'react';
2+
import { startOfDay, addDays, isEqual } from 'date-fns';
23
import { useTranslation } from 'react-i18next';
3-
// TODO: Check if we can import { addDays, addMonths ... } from 'date-fns'
4-
// without bundling all of the package then we can remove the disable-next-line
5-
// comments.
6-
7-
// eslint-disable-next-line import/no-duplicates
8-
import addDays from 'date-fns/addDays';
9-
// eslint-disable-next-line import/no-duplicates
10-
import addMonths from 'date-fns/addMonths';
11-
// eslint-disable-next-line import/no-duplicates
12-
import isEqual from 'date-fns/isEqual';
134
import { Spacer } from '@freecodecamp/ui';
14-
// eslint-disable-next-line import/no-duplicates
15-
import startOfDay from 'date-fns/startOfDay';
16-
import { User } from '../../../redux/prop-types';
5+
import { last } from 'lodash-es';
6+
import { uniq } from 'lodash';
7+
178
import { FullWidthRow } from '../../helpers';
9+
1810
import './stats.css';
1911

2012
interface StatsProps {
2113
points: number;
22-
calendar: User['calendar'];
14+
calendar: Record<string, number>;
2315
}
2416

25-
function Stats({ points, calendar }: StatsProps): JSX.Element {
26-
const { t } = useTranslation();
27-
28-
/**
29-
* the following logic calculates streaks from the
30-
* users calendar
31-
*/
32-
33-
interface PageData {
34-
startOfCalendar: Date;
35-
endOfCalendar: Date;
36-
}
37-
38-
interface CalendarData {
39-
date: Date;
40-
count: number;
41-
}
42-
43-
// create array of timestamps and turn into milliseconds
17+
export const calculateStreaks = (calendar: Record<string, number>) => {
18+
// calendar keys are timestamps in seconds and we need them in milliseconds
4419
const timestamps = Object.keys(calendar).map(
4520
stamp => Number.parseInt(stamp, 10) * 1000
4621
);
47-
const startOfTimestamps = startOfDay(new Date(timestamps[0]));
48-
let endOfCalendar = startOfDay(Date.now());
49-
let startOfCalendar;
50-
51-
const pages: PageData[] = [];
52-
53-
do {
54-
startOfCalendar = addDays(addMonths(endOfCalendar, -6), 1);
55-
56-
const newPage = {
57-
startOfCalendar: startOfCalendar,
58-
endOfCalendar: endOfCalendar
59-
};
60-
61-
pages.push(newPage);
62-
63-
endOfCalendar = addDays(startOfCalendar, -1);
64-
} while (startOfTimestamps < startOfCalendar);
65-
66-
pages.reverse();
67-
68-
const calendarData: CalendarData[] = [];
69-
let dayCounter = pages[0].startOfCalendar;
70-
71-
// create an object for each day of the calendar period
72-
while (dayCounter <= pages[pages.length - 1].endOfCalendar) {
73-
const newDay = {
74-
date: startOfDay(dayCounter),
75-
count: 0
76-
};
77-
78-
calendarData.push(newDay);
79-
dayCounter = addDays(dayCounter, 1);
80-
}
81-
82-
let longestStreak = 0;
83-
let currentStreak = 0;
84-
let lastIndex = -1;
22+
const days = uniq(timestamps.map(stamp => startOfDay(stamp)));
23+
24+
const { longestStreak, currentStreak } = days.reduce(
25+
(acc, day) => {
26+
const isConsecutive = isEqual(addDays(acc.previousDay, 1), day);
27+
const currentStreak = isConsecutive ? acc.currentStreak + 1 : 1;
28+
const longestStreak = Math.max(acc.longestStreak, currentStreak);
29+
30+
return {
31+
currentStreak,
32+
longestStreak,
33+
previousDay: day
34+
};
35+
},
36+
// the site didn't exist in 1970, so we can be confident no streak started
37+
// then
38+
{ currentStreak: 0, longestStreak: 0, previousDay: new Date(0) }
39+
);
8540

86-
// add a point to each day with a completed timestamp and calculate streaks
87-
timestamps.forEach(stamp => {
88-
const index = calendarData.findIndex(day =>
89-
isEqual(day.date, startOfDay(stamp))
90-
);
41+
const lastDay = last(days);
42+
const streakExpired = !lastDay || !isEqual(lastDay, startOfDay(Date.now()));
9143

92-
if (index >= 0) {
93-
// add one point for today
94-
calendarData[index].count++;
44+
return { longestStreak, currentStreak: streakExpired ? 0 : currentStreak };
45+
};
9546

96-
// if timestamp is on a new day, deal with streaks
97-
if (index !== lastIndex) {
98-
// if yesterday has points
99-
if (calendarData[index - 1] && calendarData[index - 1].count > 0) {
100-
currentStreak++;
101-
} else {
102-
currentStreak = 1;
103-
}
47+
function Stats({ points, calendar }: StatsProps): JSX.Element {
48+
const { t } = useTranslation();
10449

105-
if (currentStreak > longestStreak) {
106-
longestStreak = currentStreak;
107-
}
108-
}
50+
const [currentStreak, setCurrentStreak] = useState(0);
51+
const [longestStreak, setLongestStreak] = useState(0);
10952

110-
lastIndex = index;
111-
}
112-
});
53+
useEffect(() => {
54+
const { longestStreak, currentStreak } = calculateStreaks(calendar);
11355

114-
// if today has no points
115-
if (
116-
calendarData[calendarData.length - 1] &&
117-
calendarData[calendarData.length - 1].count === 0
118-
) {
119-
currentStreak = 0;
120-
}
56+
setLongestStreak(longestStreak);
57+
setCurrentStreak(currentStreak);
58+
}, [calendar]);
12159

12260
return (
12361
<FullWidthRow>

0 commit comments

Comments
 (0)