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:
Sem Bauke
2025-03-05 21:36:13 +01:00
committed by GitHub
parent 1d9e1f2018
commit 09499eec1f
2 changed files with 127 additions and 107 deletions

View File

@@ -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);
});
});

View File

@@ -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>