feat: implement user streak (#6594)
* feat: implement user streak * fix: refactor codebase * feat: streak heatmap * Add streaks --------- Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>pull/6768/head
parent
447bf4eb0f
commit
8e407c95a1
5 changed files with 447 additions and 5 deletions
@ -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<StreakResponse>({ |
||||||
|
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<StreakResponse>( |
||||||
|
`${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 ( |
||||||
|
<div className="relative z-[90] animate-fade-in"> |
||||||
|
<button |
||||||
|
className={cn( |
||||||
|
'flex items-center justify-center rounded-lg p-1.5 px-2 text-purple-400 hover:bg-purple-100/10 focus:outline-none', |
||||||
|
{ |
||||||
|
'bg-purple-100/10': showDropdown, |
||||||
|
}, |
||||||
|
)} |
||||||
|
onClick={() => setShowDropdown(true)} |
||||||
|
> |
||||||
|
<Flame className="size-5" /> |
||||||
|
<span className="ml-1 text-sm font-semibold"> |
||||||
|
{accountStreak?.count} |
||||||
|
</span> |
||||||
|
</button> |
||||||
|
|
||||||
|
{showDropdown && ( |
||||||
|
<div |
||||||
|
ref={dropdownRef} |
||||||
|
className="absolute right-0 top-full z-50 w-[320px] translate-y-1 rounded-lg bg-slate-800 shadow-xl" |
||||||
|
> |
||||||
|
<div className="px-4 py-2.5"> |
||||||
|
<div className="flex items-center justify-between gap-2 text-sm text-slate-500"> |
||||||
|
<p> |
||||||
|
Current Streak |
||||||
|
<span className="ml-2 font-medium text-white"> |
||||||
|
{accountStreak?.count || 0} |
||||||
|
</span> |
||||||
|
</p> |
||||||
|
<p> |
||||||
|
Longest Streak |
||||||
|
<span className="ml-2 font-medium text-white"> |
||||||
|
{accountStreak?.longestCount || 0} |
||||||
|
</span> |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="mb-5 mt-8"> |
||||||
|
<div className="grid grid-cols-10 gap-1"> |
||||||
|
{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 ? ( |
||||||
|
<X className="opacit size-3.5 text-white" /> |
||||||
|
) : ( |
||||||
|
<Flame className="size-3.5 text-white" /> |
||||||
|
); |
||||||
|
} 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 = <Flame className="size-3.5 text-white" />; |
||||||
|
} else { |
||||||
|
// Remaining streak days
|
||||||
|
const remainingIndex = |
||||||
|
index - leftCircleCount - currentCircleCount; |
||||||
|
dayCount = currentCount + remainingIndex + 1 + 1; |
||||||
|
isRemainingStreakDay = true; |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<StreakDay |
||||||
|
key={`streak-${index}`} |
||||||
|
dayCount={dayCount} |
||||||
|
icon={icon} |
||||||
|
isBrokenStreakDay={isBrokenStreakDay} |
||||||
|
isPreviousStreakDay={isPreviousStreakDay} |
||||||
|
isCurrentStreakDay={isCurrentStreakDay} |
||||||
|
isRemainingStreakDay={isRemainingStreakDay} |
||||||
|
isToday={isToday} |
||||||
|
/> |
||||||
|
); |
||||||
|
})} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<p className="text-center text-xs text-slate-500"> |
||||||
|
Visit every day to keep your streak alive! |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -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; |
||||||
|
} |
@ -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 ( |
||||||
|
<div className="mt-4"> |
||||||
|
<CalendarHeatmap |
||||||
|
startDate={startDate} |
||||||
|
endDate={endDate} |
||||||
|
values={[ |
||||||
|
{ |
||||||
|
date: '2024-08-01', |
||||||
|
count: 4, |
||||||
|
}, |
||||||
|
{ |
||||||
|
date: '2024-08-02', |
||||||
|
count: 10, |
||||||
|
}, |
||||||
|
{ |
||||||
|
date: '2024-08-03', |
||||||
|
count: 5, |
||||||
|
}, |
||||||
|
{ |
||||||
|
date: '2024-08-04', |
||||||
|
count: 3, |
||||||
|
}, |
||||||
|
{ |
||||||
|
date: '2024-08-05', |
||||||
|
count: 7, |
||||||
|
}, |
||||||
|
{ |
||||||
|
date: '2024-08-06', |
||||||
|
count: 2, |
||||||
|
}, |
||||||
|
{ |
||||||
|
date: '2024-08-07', |
||||||
|
count: 6, |
||||||
|
}, |
||||||
|
{ |
||||||
|
date: '2024-08-08', |
||||||
|
count: 8, |
||||||
|
}, |
||||||
|
{ |
||||||
|
date: '2024-08-09', |
||||||
|
count: 9, |
||||||
|
}, |
||||||
|
{ |
||||||
|
date: '2024-08-10', |
||||||
|
count: 1, |
||||||
|
}, |
||||||
|
{ |
||||||
|
date: '2024-08-11', |
||||||
|
count: 3, |
||||||
|
}, |
||||||
|
{ |
||||||
|
date: '2024-08-12', |
||||||
|
count: 5, |
||||||
|
}, |
||||||
|
{ |
||||||
|
date: '2024-08-13', |
||||||
|
count: 7, |
||||||
|
}, |
||||||
|
{ |
||||||
|
date: '2024-08-14', |
||||||
|
count: 8, |
||||||
|
}, |
||||||
|
{ |
||||||
|
date: '2024-08-15', |
||||||
|
count: 2, |
||||||
|
}, |
||||||
|
{ |
||||||
|
date: '2024-08-16', |
||||||
|
count: 4, |
||||||
|
}, |
||||||
|
{ |
||||||
|
date: '2024-08-17', |
||||||
|
count: 6, |
||||||
|
}, |
||||||
|
{ |
||||||
|
date: '2024-08-18', |
||||||
|
count: 8, |
||||||
|
}, |
||||||
|
{ |
||||||
|
date: '2024-08-19', |
||||||
|
count: 10, |
||||||
|
}, |
||||||
|
{ |
||||||
|
date: '2024-08-20', |
||||||
|
count: 2, |
||||||
|
}, |
||||||
|
{ |
||||||
|
date: '2024-08-21', |
||||||
|
count: 4, |
||||||
|
}, |
||||||
|
{ |
||||||
|
date: '2024-08-22', |
||||||
|
count: 6, |
||||||
|
}, |
||||||
|
{ |
||||||
|
date: '2024-08-23', |
||||||
|
count: 8, |
||||||
|
}, |
||||||
|
{ |
||||||
|
date: '2024-08-24', |
||||||
|
count: 10, |
||||||
|
}, |
||||||
|
{ |
||||||
|
date: '2024-08-25', |
||||||
|
count: 30, |
||||||
|
}, |
||||||
|
]} |
||||||
|
classForValue={(value) => { |
||||||
|
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}`, |
||||||
|
}; |
||||||
|
}} |
||||||
|
/> |
||||||
|
|
||||||
|
<ReactTooltip |
||||||
|
id="user-activity-tip" |
||||||
|
className="!rounded-lg !bg-slate-900 !p-1 !px-2 !text-xs" |
||||||
|
/> |
||||||
|
|
||||||
|
<div className="mt-2 flex items-center justify-end"> |
||||||
|
<div className="flex items-center"> |
||||||
|
<span className="mr-2 text-xs text-slate-500">Less</span> |
||||||
|
{legends.map((legend) => ( |
||||||
|
<div |
||||||
|
key={legend.count} |
||||||
|
className="flex items-center" |
||||||
|
data-tooltip-id="user-activity-tip" |
||||||
|
data-tooltip-content={`${legend.count} Updates`} |
||||||
|
> |
||||||
|
<div |
||||||
|
className={`h-2.5 w-2.5 ${legend.color} mr-1 rounded-sm`} |
||||||
|
></div> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
<span className="ml-2 text-xs text-slate-500">More</span> |
||||||
|
<ReactTooltip |
||||||
|
id="user-activity-tip" |
||||||
|
className="!rounded-lg !bg-slate-900 !p-1 !px-2 !text-sm" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -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 ( |
||||||
|
<div |
||||||
|
className={cn( |
||||||
|
'flex flex-col items-center justify-center gap-1.5', |
||||||
|
isCurrentStreakDay && 'relative', |
||||||
|
)} |
||||||
|
> |
||||||
|
<div |
||||||
|
className={cn('flex size-6 items-center justify-center rounded-full', { |
||||||
|
'bg-red-500': isPreviousStreakDay, |
||||||
|
'bg-purple-500': isCurrentStreakDay, |
||||||
|
'bg-slate-700': isRemainingStreakDay, |
||||||
|
'border-2 border-dashed border-slate-500 bg-transparent': isToday, |
||||||
|
})} |
||||||
|
> |
||||||
|
{isToday ? null : icon} |
||||||
|
</div> |
||||||
|
<span |
||||||
|
className={cn('text-sm', { |
||||||
|
'text-slate-500': isPreviousStreakDay, |
||||||
|
'text-slate-100': isCurrentStreakDay || isRemainingStreakDay, |
||||||
|
})} |
||||||
|
> |
||||||
|
{dayCount} |
||||||
|
</span> |
||||||
|
{isToday && ( |
||||||
|
<ChevronDown className="absolute bottom-full left-1/2 h-4 w-4 -translate-x-1/2 transform stroke-[2.5px] text-slate-400" /> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
Loading…
Reference in new issue