diff --git a/client/src/components/profile/components/stats.test.tsx b/client/src/components/profile/components/stats.test.tsx
index 8c01721c4f4..9e37d662c87 100644
--- a/client/src/components/profile/components/stats.test.tsx
+++ b/client/src/components/profile/components/stats.test.tsx
@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
-import Stats from './stats';
+import Stats, { calculateStreaks } from './stats';
const props: { calendar: { [key: number]: number }; points: number } = {
calendar: {},
@@ -22,3 +22,85 @@ describe('', () => {
);
});
});
+
+const oldStreakCalendar = {
+ '1736496000': 1, // 2025-01-10 08:00:00 UTC
+ '1736582400': 1, // 2025-01-11 08:00:00 UTC
+ '1736668800': 1, // 2025-01-12 08:00:00 UTC
+ '1736755200': 1, // 2025-01-13 08:00:00 UTC
+ '1736841600': 1 // 2025-01-14 08:00:00 UTC
+};
+
+const recentStreakCalendar = {
+ '1736699400': 1, // 2025-01-12 16:30:00 UTC
+ '1736763300': 1, // 2025-01-13 10:15:00 UTC
+ '1736865900': 1 // 2025-01-14 14:45:00 UTC
+};
+
+const twoStreakCalendar = {
+ '1736503200': 1, // 2025-01-10 10:00:00 UTC
+ '1736604000': 1, // 2025-01-11 14:00:00 UTC
+ '1736697600': 1, // 2025-01-12 16:00:00 UTC
+ // Skipping Jan 13, 2025
+ '1736845200': 1, // 2025-01-14 09:00:00 UTC
+ '1736946000': 1 // 2025-01-15 13:00:00 UTC
+};
+
+jest.useFakeTimers();
+
+describe('calculateStreaks', () => {
+ test('Should return a longest streak of 5 days when the user has not completed a challenge in a while', () => {
+ jest.setSystemTime(new Date(2025, 0, 15));
+ const { longestStreak, currentStreak } =
+ calculateStreaks(oldStreakCalendar);
+
+ expect(longestStreak).toBe(5);
+ expect(currentStreak).toBe(0);
+ });
+
+ test('Should calculate longest streak, regardless of how long ago they were', () => {
+ jest.setSystemTime(new Date(2030, 0, 15));
+ const { longestStreak, currentStreak } =
+ calculateStreaks(oldStreakCalendar);
+
+ expect(longestStreak).toBe(5);
+ expect(currentStreak).toBe(0);
+ });
+
+ test('Should return a longest streak of 3 days when the current streak is 3 days', () => {
+ jest.setSystemTime(new Date(2025, 0, 14));
+ const { longestStreak, currentStreak } =
+ calculateStreaks(recentStreakCalendar);
+
+ expect(longestStreak).toBe(3);
+ expect(currentStreak).toBe(3);
+ });
+
+ test('Should return a longest and current streaks of 1 day when the user has recently completed their first challenge', () => {
+ const now = new Date(2025, 0, 15);
+ jest.setSystemTime(now);
+ const calendar = {
+ [now.valueOf() / 1000]: 1
+ };
+
+ const { longestStreak, currentStreak } = calculateStreaks(calendar);
+
+ expect(longestStreak).toBe(1);
+ expect(currentStreak).toBe(1);
+ });
+
+ 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', () => {
+ const { longestStreak, currentStreak } =
+ calculateStreaks(twoStreakCalendar);
+
+ expect(longestStreak).toBe(3);
+ expect(currentStreak).toBe(2);
+ });
+
+ test('Should return a streak of 0 days if no challenges have been completed', () => {
+ const { longestStreak, currentStreak } = calculateStreaks({});
+
+ expect(longestStreak).toBe(0);
+ expect(currentStreak).toBe(0);
+ });
+});
diff --git a/client/src/components/profile/components/stats.tsx b/client/src/components/profile/components/stats.tsx
index 3ffa2d76514..2a46f86f175 100644
--- a/client/src/components/profile/components/stats.tsx
+++ b/client/src/components/profile/components/stats.tsx
@@ -1,123 +1,61 @@
-import React from 'react';
+import React, { useState, useEffect } from 'react';
+import { startOfDay, addDays, isEqual } from 'date-fns';
import { useTranslation } from 'react-i18next';
-// TODO: Check if we can import { addDays, addMonths ... } from 'date-fns'
-// without bundling all of the package then we can remove the disable-next-line
-// comments.
-
-// eslint-disable-next-line import/no-duplicates
-import addDays from 'date-fns/addDays';
-// eslint-disable-next-line import/no-duplicates
-import addMonths from 'date-fns/addMonths';
-// eslint-disable-next-line import/no-duplicates
-import isEqual from 'date-fns/isEqual';
import { Spacer } from '@freecodecamp/ui';
-// eslint-disable-next-line import/no-duplicates
-import startOfDay from 'date-fns/startOfDay';
-import { User } from '../../../redux/prop-types';
+import { last } from 'lodash-es';
+import { uniq } from 'lodash';
+
import { FullWidthRow } from '../../helpers';
+
import './stats.css';
interface StatsProps {
points: number;
- calendar: User['calendar'];
+ calendar: Record;
}
+export const calculateStreaks = (calendar: Record) => {
+ // calendar keys are timestamps in seconds and we need them in milliseconds
+ const timestamps = Object.keys(calendar).map(
+ stamp => Number.parseInt(stamp, 10) * 1000
+ );
+ const days = uniq(timestamps.map(stamp => startOfDay(stamp)));
+
+ const { longestStreak, currentStreak } = days.reduce(
+ (acc, day) => {
+ const isConsecutive = isEqual(addDays(acc.previousDay, 1), day);
+ const currentStreak = isConsecutive ? acc.currentStreak + 1 : 1;
+ const longestStreak = Math.max(acc.longestStreak, currentStreak);
+
+ return {
+ currentStreak,
+ longestStreak,
+ previousDay: day
+ };
+ },
+ // the site didn't exist in 1970, so we can be confident no streak started
+ // then
+ { currentStreak: 0, longestStreak: 0, previousDay: new Date(0) }
+ );
+
+ const lastDay = last(days);
+ const streakExpired = !lastDay || !isEqual(lastDay, startOfDay(Date.now()));
+
+ return { longestStreak, currentStreak: streakExpired ? 0 : currentStreak };
+};
+
function Stats({ points, calendar }: StatsProps): JSX.Element {
const { t } = useTranslation();
- /**
- * the following logic calculates streaks from the
- * users calendar
- */
+ const [currentStreak, setCurrentStreak] = useState(0);
+ const [longestStreak, setLongestStreak] = useState(0);
- interface PageData {
- startOfCalendar: Date;
- endOfCalendar: Date;
- }
+ useEffect(() => {
+ const { longestStreak, currentStreak } = calculateStreaks(calendar);
- 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;
- }
+ setLongestStreak(longestStreak);
+ setCurrentStreak(currentStreak);
+ }, [calendar]);
return (