|
1 | | -import React from 'react'; |
| 1 | +import React, { useState, useEffect } from 'react'; |
| 2 | +import { startOfDay, addDays, isEqual } from 'date-fns'; |
2 | 3 | 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'; |
13 | 4 | 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 | + |
17 | 8 | import { FullWidthRow } from '../../helpers'; |
| 9 | + |
18 | 10 | import './stats.css'; |
19 | 11 |
|
20 | 12 | interface StatsProps { |
21 | 13 | points: number; |
22 | | - calendar: User['calendar']; |
| 14 | + calendar: Record<string, number>; |
23 | 15 | } |
24 | 16 |
|
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 |
44 | 19 | const timestamps = Object.keys(calendar).map( |
45 | 20 | stamp => Number.parseInt(stamp, 10) * 1000 |
46 | 21 | ); |
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 | + ); |
85 | 40 |
|
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())); |
91 | 43 |
|
92 | | - if (index >= 0) { |
93 | | - // add one point for today |
94 | | - calendarData[index].count++; |
| 44 | + return { longestStreak, currentStreak: streakExpired ? 0 : currentStreak }; |
| 45 | +}; |
95 | 46 |
|
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(); |
104 | 49 |
|
105 | | - if (currentStreak > longestStreak) { |
106 | | - longestStreak = currentStreak; |
107 | | - } |
108 | | - } |
| 50 | + const [currentStreak, setCurrentStreak] = useState(0); |
| 51 | + const [longestStreak, setLongestStreak] = useState(0); |
109 | 52 |
|
110 | | - lastIndex = index; |
111 | | - } |
112 | | - }); |
| 53 | + useEffect(() => { |
| 54 | + const { longestStreak, currentStreak } = calculateStreaks(calendar); |
113 | 55 |
|
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]); |
121 | 59 |
|
122 | 60 | return ( |
123 | 61 | <FullWidthRow> |
|
0 commit comments