From 09499eec1f2eaedaa5f801712fcb0f1783e5ccb1 Mon Sep 17 00:00:00 2001 From: Sem Bauke Date: Wed, 5 Mar 2025 21:36:13 +0100 Subject: [PATCH] fix(profile): simplify stats component and allow streaks to exceed 6 months (#58763) Co-authored-by: Oliver Eyton-Williams --- .../profile/components/stats.test.tsx | 84 +++++++++- .../components/profile/components/stats.tsx | 150 +++++------------- 2 files changed, 127 insertions(+), 107 deletions(-) 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 (