mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-01-05 21:03:24 -05:00
fix(profile): simplify stats component and allow streaks to exceed 6 months (#58763)
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
@@ -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('<Stats/>', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, number>;
|
||||
}
|
||||
|
||||
export const calculateStreaks = (calendar: Record<string, number>) => {
|
||||
// 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 (
|
||||
<FullWidthRow>
|
||||
|
||||
Reference in New Issue
Block a user