diff --git a/src/components/AccountStreak/AccountStreak.tsx b/src/components/AccountStreak/AccountStreak.tsx new file mode 100644 index 000000000..50240329f --- /dev/null +++ b/src/components/AccountStreak/AccountStreak.tsx @@ -0,0 +1,191 @@ +import { useEffect, useRef, useState } from 'react'; +import { isLoggedIn } from '../../lib/jwt'; +import { httpGet } from '../../lib/http'; +import { useToast } from '../../hooks/use-toast'; +import { Flame, X } from 'lucide-react'; +import { useOutsideClick } from '../../hooks/use-outside-click'; +import { StreakDay } from './StreakDay'; +import { + navigationDropdownOpen, + roadmapsDropdownOpen, +} from '../../stores/page.ts'; +import { useStore } from '@nanostores/react'; +import { cn } from '../../lib/classname.ts'; + +type StreakResponse = { + count: number; + longestCount: number; + previousCount?: number | null; + firstVisitAt: Date; + lastVisitAt: Date; +}; + +type AccountStreakProps = {}; + +export function AccountStreak(props: AccountStreakProps) { + const toast = useToast(); + const dropdownRef = useRef(null); + + const [isLoading, setIsLoading] = useState(true); + const [accountStreak, setAccountStreak] = useState({ + count: 0, + longestCount: 0, + firstVisitAt: new Date(), + lastVisitAt: new Date(), + }); + const [showDropdown, setShowDropdown] = useState(false); + + const $roadmapsDropdownOpen = useStore(roadmapsDropdownOpen); + const $navigationDropdownOpen = useStore(navigationDropdownOpen); + + useEffect(() => { + if ($roadmapsDropdownOpen || $navigationDropdownOpen) { + setShowDropdown(false); + } + }, [$roadmapsDropdownOpen, $navigationDropdownOpen]); + + const loadAccountStreak = async () => { + if (!isLoggedIn()) { + return; + } + + setIsLoading(true); + const { response, error } = await httpGet( + `${import.meta.env.PUBLIC_API_URL}/v1-streak`, + ); + + if (error || !response) { + toast.error(error?.message || 'Failed to load account streak'); + setIsLoading(false); + return; + } + + setAccountStreak(response); + setIsLoading(false); + }; + + useOutsideClick(dropdownRef, () => { + setShowDropdown(false); + }); + + useEffect(() => { + loadAccountStreak().finally(() => {}); + }, []); + + if (!isLoggedIn() || isLoading) { + return null; + } + + let { count: currentCount } = accountStreak; + const previousCount = + accountStreak?.previousCount || accountStreak?.count || 0; + + // Adding one to show the current day + const currentCircleCount = Math.min(currentCount, 5) + 1; + // Adding one day to show the streak they broke + const leftCircleCount = Math.min(5 - currentCircleCount, previousCount) + 1; + // In the maximum case, we will show 10 circles + const remainingCount = Math.max(0, 10 - leftCircleCount - currentCircleCount); + const totalCircles = leftCircleCount + currentCircleCount + remainingCount; + + return ( +
+ + + {showDropdown && ( +
+
+
+

+ Current Streak + + {accountStreak?.count || 0} + +

+

+ Longest Streak + + {accountStreak?.longestCount || 0} + +

+
+ +
+
+ {Array.from({ length: totalCircles }).map((_, index) => { + let dayCount, + icon, + isPreviousStreakDay, + isBrokenStreakDay, + isCurrentStreakDay, + isRemainingStreakDay, + isToday; + + if (index < leftCircleCount) { + // Previous streak days + dayCount = previousCount - leftCircleCount + index + 1 + 1; + isPreviousStreakDay = true; + isBrokenStreakDay = index === leftCircleCount - 1; + + icon = isBrokenStreakDay ? ( + + ) : ( + + ); + } else if (index < leftCircleCount + currentCircleCount) { + // Current streak days + const currentIndex = index - leftCircleCount; + dayCount = + currentCount - currentCircleCount + currentIndex + 1 + 1; + isCurrentStreakDay = true; + isToday = currentIndex === currentCircleCount - 1; + icon = ; + } else { + // Remaining streak days + const remainingIndex = + index - leftCircleCount - currentCircleCount; + dayCount = currentCount + remainingIndex + 1 + 1; + isRemainingStreakDay = true; + } + + return ( + + ); + })} +
+
+ +

+ Visit every day to keep your streak alive! +

+
+
+ )} +
+ ); +} diff --git a/src/components/AccountStreak/AccountStreakHeatmap.css b/src/components/AccountStreak/AccountStreakHeatmap.css new file mode 100644 index 000000000..62ab68cc5 --- /dev/null +++ b/src/components/AccountStreak/AccountStreakHeatmap.css @@ -0,0 +1,7 @@ +.react-calendar-heatmap text { + fill: rgb(148, 163, 184) !important; +} + +.react-calendar-heatmap rect:hover { + stroke: rgb(148, 163, 184) !important; +} \ No newline at end of file diff --git a/src/components/AccountStreak/AccountStreakHeatmap.tsx b/src/components/AccountStreak/AccountStreakHeatmap.tsx new file mode 100644 index 000000000..0e7daaba4 --- /dev/null +++ b/src/components/AccountStreak/AccountStreakHeatmap.tsx @@ -0,0 +1,189 @@ +import CalendarHeatmap from 'react-calendar-heatmap'; +import dayjs from 'dayjs'; +import { formatActivityDate } from '../../lib/date'; +import { Tooltip as ReactTooltip } from 'react-tooltip'; +import 'react-calendar-heatmap/dist/styles.css'; +import './AccountStreakHeatmap.css'; + +const legends = [ + { count: 1, color: 'bg-slate-600' }, + { count: 3, color: 'bg-slate-500' }, + { count: 5, color: 'bg-slate-400' }, + { count: 10, color: 'bg-slate-300' }, + { count: 20, color: 'bg-slate-200' }, +]; + +type AccountStreakHeatmapProps = {}; + +export function AccountStreakHeatmap(props: AccountStreakHeatmapProps) { + const startDate = dayjs().subtract(6, 'months').toDate(); + const endDate = dayjs().toDate(); + + return ( +
+ { + if (!value) { + return 'fill-slate-700 rounded-md [rx:2px] focus:outline-none'; + } + + const { count } = value; + if (count >= 20) { + return 'fill-slate-200 rounded-md [rx:2px] focus:outline-none'; + } else if (count >= 10) { + return 'fill-slate-300 rounded-md [rx:2px] focus:outline-none'; + } else if (count >= 5) { + return 'fill-slate-400 rounded-md [rx:2px] focus:outline-none'; + } else if (count >= 3) { + return 'fill-slate-500 rounded-md [rx:2px] focus:outline-none'; + } else { + return 'fill-slate-600 rounded-md [rx:2px] focus:outline-none'; + } + }} + tooltipDataAttrs={(value: any) => { + if (!value || !value.date) { + return null; + } + + const formattedDate = formatActivityDate(value.date); + return { + 'data-tooltip-id': 'user-activity-tip', + 'data-tooltip-content': `${value.count} Updates - ${formattedDate}`, + }; + }} + /> + + + +
+
+ Less + {legends.map((legend) => ( +
+
+
+ ))} + More + +
+
+
+ ); +} diff --git a/src/components/AccountStreak/StreakDay.tsx b/src/components/AccountStreak/StreakDay.tsx new file mode 100644 index 000000000..81e2d4fea --- /dev/null +++ b/src/components/AccountStreak/StreakDay.tsx @@ -0,0 +1,56 @@ +import type { ReactNode } from 'react'; +import { cn } from '../../lib/classname'; +import { ChevronDown } from 'lucide-react'; + +type StreakDayProps = { + isToday?: boolean; + isCurrentStreakDay?: boolean; + isPreviousStreakDay?: boolean; + isBrokenStreakDay?: boolean; + isRemainingStreakDay?: boolean; + dayCount: number; + icon?: ReactNode; +}; + +export function StreakDay(props: StreakDayProps) { + const { + isCurrentStreakDay, + isPreviousStreakDay, + isBrokenStreakDay, + isRemainingStreakDay, + dayCount, + icon, + isToday = false, + } = props; + + return ( +
+
+ {isToday ? null : icon} +
+ + {dayCount} + + {isToday && ( + + )} +
+ ); +} diff --git a/src/components/Navigation/Navigation.astro b/src/components/Navigation/Navigation.astro index 88880fc70..c85f11b3b 100644 --- a/src/components/Navigation/Navigation.astro +++ b/src/components/Navigation/Navigation.astro @@ -4,6 +4,7 @@ import Icon from '../AstroIcon.astro'; import { NavigationDropdown } from '../NavigationDropdown'; import { AccountDropdown } from './AccountDropdown'; import NewIndicator from './NewIndicator.astro'; +import { AccountStreak } from '../AccountStreak/AccountStreak'; import { RoadmapDropdownMenu } from '../RoadmapDropdownMenu/RoadmapDropdownMenu'; --- @@ -42,10 +43,7 @@ import { RoadmapDropdownMenu } from '../RoadmapDropdownMenu/RoadmapDropdownMenu' Start Here - + Teams @@ -55,7 +53,8 @@ import { RoadmapDropdownMenu } from '../RoadmapDropdownMenu/RoadmapDropdownMenu' -
  • +
  • +