mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-02 13:52:46 +02:00
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>
This commit is contained in:
191
src/components/AccountStreak/AccountStreak.tsx
Normal file
191
src/components/AccountStreak/AccountStreak.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
7
src/components/AccountStreak/AccountStreakHeatmap.css
Normal file
7
src/components/AccountStreak/AccountStreakHeatmap.css
Normal file
@@ -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;
|
||||||
|
}
|
189
src/components/AccountStreak/AccountStreakHeatmap.tsx
Normal file
189
src/components/AccountStreak/AccountStreakHeatmap.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
56
src/components/AccountStreak/StreakDay.tsx
Normal file
56
src/components/AccountStreak/StreakDay.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
@@ -4,6 +4,7 @@ import Icon from '../AstroIcon.astro';
|
|||||||
import { NavigationDropdown } from '../NavigationDropdown';
|
import { NavigationDropdown } from '../NavigationDropdown';
|
||||||
import { AccountDropdown } from './AccountDropdown';
|
import { AccountDropdown } from './AccountDropdown';
|
||||||
import NewIndicator from './NewIndicator.astro';
|
import NewIndicator from './NewIndicator.astro';
|
||||||
|
import { AccountStreak } from '../AccountStreak/AccountStreak';
|
||||||
import { RoadmapDropdownMenu } from '../RoadmapDropdownMenu/RoadmapDropdownMenu';
|
import { RoadmapDropdownMenu } from '../RoadmapDropdownMenu/RoadmapDropdownMenu';
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -42,10 +43,7 @@ import { RoadmapDropdownMenu } from '../RoadmapDropdownMenu/RoadmapDropdownMenu'
|
|||||||
Start Here
|
Start Here
|
||||||
</a>
|
</a>
|
||||||
<RoadmapDropdownMenu client:load />
|
<RoadmapDropdownMenu client:load />
|
||||||
<a
|
<a href='/teams' class='group relative text-gray-400 hover:text-white'>
|
||||||
href='/teams'
|
|
||||||
class='group relative !mr-5 text-gray-400 hover:text-white'
|
|
||||||
>
|
|
||||||
Teams
|
Teams
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,7 +53,8 @@ import { RoadmapDropdownMenu } from '../RoadmapDropdownMenu/RoadmapDropdownMenu'
|
|||||||
<li data-guest-required class='hidden'>
|
<li data-guest-required class='hidden'>
|
||||||
<a href='/login' class='text-gray-400 hover:text-white'>Login</a>
|
<a href='/login' class='text-gray-400 hover:text-white'>Login</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li class='flex items-center gap-2'>
|
||||||
|
<AccountStreak client:only='react' />
|
||||||
<AccountDropdown client:only='react' />
|
<AccountDropdown client:only='react' />
|
||||||
|
|
||||||
<a
|
<a
|
||||||
|
Reference in New Issue
Block a user