feat: add streak stats

feat/dashboard
Arik Chakma 6 months ago
parent 3a5fdb656f
commit 445bb3ad2a
  1. 23
      src/components/AccountStreak/AccountStreak.tsx
  2. 30
      src/components/Dashboard/PersonalDashboard.tsx
  3. 289
      src/components/Dashboard/ProgressStack.tsx
  4. 11
      src/stores/streak.ts

@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { isLoggedIn } from '../../lib/jwt'; import { isLoggedIn } from '../../lib/jwt';
import { httpGet } from '../../lib/http'; import { httpGet } from '../../lib/http';
import { useToast } from '../../hooks/use-toast'; import { useToast } from '../../hooks/use-toast';
import {Flame, X, Zap, ZapOff} from 'lucide-react'; import { Flame, X, Zap, ZapOff } from 'lucide-react';
import { useOutsideClick } from '../../hooks/use-outside-click'; import { useOutsideClick } from '../../hooks/use-outside-click';
import { StreakDay } from './StreakDay'; import { StreakDay } from './StreakDay';
import { import {
@ -11,6 +11,7 @@ import {
} from '../../stores/page.ts'; } from '../../stores/page.ts';
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import { cn } from '../../lib/classname.ts'; import { cn } from '../../lib/classname.ts';
import { $accountStreak } from '../../stores/streak.ts';
type StreakResponse = { type StreakResponse = {
count: number; count: number;
@ -27,12 +28,7 @@ export function AccountStreak(props: AccountStreakProps) {
const dropdownRef = useRef(null); const dropdownRef = useRef(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [accountStreak, setAccountStreak] = useState<StreakResponse>({ const accountStreak = useStore($accountStreak);
count: 0,
longestCount: 0,
firstVisitAt: new Date(),
lastVisitAt: new Date(),
});
const [showDropdown, setShowDropdown] = useState(false); const [showDropdown, setShowDropdown] = useState(false);
const $roadmapsDropdownOpen = useStore(roadmapsDropdownOpen); const $roadmapsDropdownOpen = useStore(roadmapsDropdownOpen);
@ -49,6 +45,11 @@ export function AccountStreak(props: AccountStreakProps) {
return; return;
} }
if (accountStreak) {
setIsLoading(false);
return;
}
setIsLoading(true); setIsLoading(true);
const { response, error } = await httpGet<StreakResponse>( const { response, error } = await httpGet<StreakResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-streak`, `${import.meta.env.PUBLIC_API_URL}/v1-streak`,
@ -60,7 +61,7 @@ export function AccountStreak(props: AccountStreakProps) {
return; return;
} }
setAccountStreak(response); $accountStreak.set(response);
setIsLoading(false); setIsLoading(false);
}; };
@ -76,7 +77,7 @@ export function AccountStreak(props: AccountStreakProps) {
return null; return null;
} }
let { count: currentCount } = accountStreak; let { count: currentCount = 0 } = accountStreak || {};
const previousCount = const previousCount =
accountStreak?.previousCount || accountStreak?.count || 0; accountStreak?.previousCount || accountStreak?.count || 0;
@ -110,7 +111,7 @@ export function AccountStreak(props: AccountStreakProps) {
ref={dropdownRef} ref={dropdownRef}
className="absolute right-0 top-full z-50 w-[335px] translate-y-1 rounded-lg bg-slate-800 shadow-xl" className="absolute right-0 top-full z-50 w-[335px] translate-y-1 rounded-lg bg-slate-800 shadow-xl"
> >
<div className="pl-4 pr-5 py-3"> <div className="py-3 pl-4 pr-5">
<div className="flex items-center justify-between gap-2 text-sm text-slate-500"> <div className="flex items-center justify-between gap-2 text-sm text-slate-500">
<p> <p>
Current Streak Current Streak
@ -180,7 +181,7 @@ export function AccountStreak(props: AccountStreakProps) {
</div> </div>
</div> </div>
<p className="text-center text-xs text-slate-600 tracking-wide mb-[1.75px] -mt-[0px]"> <p className="-mt-[0px] mb-[1.75px] text-center text-xs tracking-wide text-slate-600">
Visit every day to keep your streak alive! Visit every day to keep your streak alive!
</p> </p>
</div> </div>

@ -8,6 +8,8 @@ import { getCurrentPeriod } from '../../lib/date';
import { ListDashboardCustomProgress } from './ListDashboardCustomProgress'; import { ListDashboardCustomProgress } from './ListDashboardCustomProgress';
import { RecommendedRoadmaps } from './RecommendedRoadmaps'; import { RecommendedRoadmaps } from './RecommendedRoadmaps';
import { ProgressStack } from './ProgressStack'; import { ProgressStack } from './ProgressStack';
import { useStore } from '@nanostores/react';
import { $accountStreak, type StreakResponse } from '../../stores/streak';
type UserDashboardResponse = { type UserDashboardResponse = {
name: string; name: string;
@ -46,6 +48,25 @@ export function PersonalDashboard(props: PersonalDashboardProps) {
const [personalDashboardDetails, setPersonalDashboardDetails] = const [personalDashboardDetails, setPersonalDashboardDetails] =
useState<UserDashboardResponse>(); useState<UserDashboardResponse>();
const [projectDetails, setProjectDetails] = useState<PageType[]>([]); const [projectDetails, setProjectDetails] = useState<PageType[]>([]);
const accountStreak = useStore($accountStreak);
const loadAccountStreak = async () => {
if (accountStreak) {
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');
return;
}
$accountStreak.set(response);
};
async function loadProgress() { async function loadProgress() {
const { response: progressList, error } = const { response: progressList, error } =
@ -88,9 +109,11 @@ export function PersonalDashboard(props: PersonalDashboardProps) {
} }
useEffect(() => { useEffect(() => {
Promise.allSettled([loadProgress(), loadAllProjectDetails()]).finally(() => Promise.allSettled([
setIsLoading(false), loadProgress(),
); loadAllProjectDetails(),
loadAccountStreak(),
]).finally(() => setIsLoading(false));
}, []); }, []);
useEffect(() => { useEffect(() => {
@ -252,6 +275,7 @@ export function PersonalDashboard(props: PersonalDashboardProps) {
progresses={learningRoadmapsToShow} progresses={learningRoadmapsToShow}
projects={enrichedProjects || []} projects={enrichedProjects || []}
isLoading={isLoading} isLoading={isLoading}
accountStreak={accountStreak}
/> />
<ListDashboardCustomProgress <ListDashboardCustomProgress

@ -1,27 +1,20 @@
import { import { ArrowUpRight } from 'lucide-react';
ArrowUpRight,
Bookmark,
Check,
CheckCircle,
CheckIcon,
CircleCheck,
CircleDashed,
} from 'lucide-react';
import { ResourceProgress } from '../Activity/ResourceProgress';
import type { UserProgress } from '../TeamProgress/TeamProgressPage'; import type { UserProgress } from '../TeamProgress/TeamProgressPage';
import { getPercentage } from '../../helper/number';
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions'; import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions';
import { DashboardBookmarkCard } from './DashboardBookmarkCard'; import { DashboardBookmarkCard } from './DashboardBookmarkCard';
import { DashboardProjectCard } from './DashboardProjectCard'; import { DashboardProjectCard } from './DashboardProjectCard';
import { useState } from 'react'; import { useState } from 'react';
import { cn } from '../../lib/classname'; import { cn } from '../../lib/classname';
import { DashboardProgressCard } from './DashboardProgressCard'; import { DashboardProgressCard } from './DashboardProgressCard';
import { useStore } from '@nanostores/react';
import { $accountStreak, type StreakResponse } from '../../stores/streak';
type ProgressStackProps = { type ProgressStackProps = {
progresses: UserProgress[]; progresses: UserProgress[];
projects: (ProjectStatusDocument & { projects: (ProjectStatusDocument & {
title: string; title: string;
})[]; })[];
accountStreak?: StreakResponse;
isLoading: boolean; isLoading: boolean;
}; };
@ -30,7 +23,7 @@ const MAX_PROJECTS_TO_SHOW = 8;
const MAX_BOOKMARKS_TO_SHOW = 8; const MAX_BOOKMARKS_TO_SHOW = 8;
export function ProgressStack(props: ProgressStackProps) { export function ProgressStack(props: ProgressStackProps) {
const { progresses, projects, isLoading } = props; const { progresses, projects, isLoading, accountStreak } = props;
const bookmarkedProgresses = progresses.filter( const bookmarkedProgresses = progresses.filter(
(progress) => (progress) =>
@ -57,135 +50,154 @@ export function ProgressStack(props: ProgressStackProps) {
? bookmarkedProgresses ? bookmarkedProgresses
: bookmarkedProgresses.slice(0, MAX_BOOKMARKS_TO_SHOW); : bookmarkedProgresses.slice(0, MAX_BOOKMARKS_TO_SHOW);
const totalProjectFinished = projects.filter(
(project) => project.repositoryUrl,
).length;
return ( return (
<div className="mt-2 grid min-h-[330px] grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3"> <>
<div className="h-full rounded-md border bg-white p-4 shadow-sm"> <div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
<h3 className="text-xs uppercase text-gray-500">Your Progress</h3> <StatsCard
title="Current Streak"
<div className="mt-4 flex flex-col gap-2"> value={accountStreak?.count || 0}
{isLoading ? ( isLoading={isLoading}
<> />
<CardSkeleton /> <StatsCard
<CardSkeleton /> title="Projects Finished"
<CardSkeleton /> value={totalProjectFinished}
<CardSkeleton /> isLoading={isLoading}
<CardSkeleton /> />
</> </div>
) : (
<> <div className="mt-2 grid min-h-[330px] grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
{userProgressesToShow.map((progress) => { <div className="h-full rounded-md border bg-white p-4 shadow-sm">
return ( <h3 className="text-xs uppercase text-gray-500">Your Progress</h3>
<DashboardProgressCard
key={progress.resourceId} <div className="mt-4 flex flex-col gap-2">
progress={progress} {isLoading ? (
/> <>
); <CardSkeleton />
})} <CardSkeleton />
</> <CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</>
) : (
<>
{userProgressesToShow.map((progress) => {
return (
<DashboardProgressCard
key={progress.resourceId}
progress={progress}
/>
);
})}
</>
)}
</div>
{userProgresses.length > MAX_PROGRESS_TO_SHOW && (
<ShowAllButton
showAll={showAllProgresses}
setShowAll={setShowAllProgresses}
count={userProgresses.length}
maxCount={MAX_PROGRESS_TO_SHOW}
className="mt-3"
/>
)} )}
</div> </div>
{userProgresses.length > MAX_PROGRESS_TO_SHOW && ( <div className="h-full rounded-md border bg-white p-4 shadow-sm">
<ShowAllButton <h3 className="text-xs uppercase text-gray-500">Projects</h3>
showAll={showAllProgresses}
setShowAll={setShowAllProgresses}
count={userProgresses.length}
maxCount={MAX_PROGRESS_TO_SHOW}
className="mt-3"
/>
)}
</div>
<div className="h-full rounded-md border bg-white p-4 shadow-sm"> <div className="mt-4 flex flex-col gap-2.5">
<h3 className="text-xs uppercase text-gray-500">Projects</h3> {isLoading ? (
<>
<div className="mt-4 flex flex-col gap-2.5"> <CardSkeleton className="h-5" />
{isLoading ? ( <CardSkeleton className="h-5" />
<> <CardSkeleton className="h-5" />
<CardSkeleton className="h-5" /> <CardSkeleton className="h-5" />
<CardSkeleton className="h-5" /> <CardSkeleton className="h-5" />
<CardSkeleton className="h-5" /> <CardSkeleton className="h-5" />
<CardSkeleton className="h-5" /> <CardSkeleton className="h-5" />
<CardSkeleton className="h-5" /> <CardSkeleton className="h-5" />
<CardSkeleton className="h-5" /> </>
<CardSkeleton className="h-5" /> ) : (
<CardSkeleton className="h-5" /> <>
</> {projectsToShow.map((project) => {
) : ( return (
<> <DashboardProjectCard
{projectsToShow.map((project) => { key={project.projectId}
return ( project={project}
<DashboardProjectCard />
key={project.projectId} );
project={project} })}
/> </>
); )}
})} </div>
</>
{projects.length > MAX_PROJECTS_TO_SHOW && (
<ShowAllButton
showAll={showAllProjects}
setShowAll={setShowAllProjects}
count={projects.length}
maxCount={MAX_PROJECTS_TO_SHOW}
className="mt-3"
/>
)} )}
</div> </div>
{projects.length > MAX_PROJECTS_TO_SHOW && ( <div className="h-full rounded-md border bg-white p-4 shadow-sm">
<ShowAllButton <div className="flex items-center justify-between gap-2">
showAll={showAllProjects} <h3 className="text-xs uppercase text-gray-500">Bookmarks</h3>
setShowAll={setShowAllProjects}
count={projects.length}
maxCount={MAX_PROJECTS_TO_SHOW}
className="mt-3"
/>
)}
</div>
<div className="h-full rounded-md border bg-white p-4 shadow-sm"> <a
<div className="flex items-center justify-between gap-2"> href="/roadmaps"
<h3 className="text-xs uppercase text-gray-500">Bookmarks</h3> className="flex items-center gap-1 text-xs text-gray-500"
>
<a <ArrowUpRight size={12} />
href="/roadmaps" Explore
className="flex items-center gap-1 text-xs text-gray-500" </a>
> </div>
<ArrowUpRight size={12} />
Explore
</a>
</div>
<div className="mt-4 flex flex-col gap-2.5"> <div className="mt-4 flex flex-col gap-2.5">
{isLoading ? ( {isLoading ? (
<> <>
<CardSkeleton className="h-5" /> <CardSkeleton className="h-5" />
<CardSkeleton className="h-5" /> <CardSkeleton className="h-5" />
<CardSkeleton className="h-5" /> <CardSkeleton className="h-5" />
<CardSkeleton className="h-5" /> <CardSkeleton className="h-5" />
<CardSkeleton className="h-5" /> <CardSkeleton className="h-5" />
<CardSkeleton className="h-5" /> <CardSkeleton className="h-5" />
<CardSkeleton className="h-5" /> <CardSkeleton className="h-5" />
<CardSkeleton className="h-5" /> <CardSkeleton className="h-5" />
</> </>
) : ( ) : (
<> <>
{bookmarksToShow.map((progress) => { {bookmarksToShow.map((progress) => {
return ( return (
<DashboardBookmarkCard <DashboardBookmarkCard
key={progress.resourceId} key={progress.resourceId}
bookmark={progress} bookmark={progress}
/> />
); );
})} })}
</> </>
)}
</div>
{bookmarkedProgresses.length > MAX_BOOKMARKS_TO_SHOW && (
<ShowAllButton
showAll={showAllBookmarks}
setShowAll={setShowAllBookmarks}
count={bookmarkedProgresses.length}
maxCount={MAX_BOOKMARKS_TO_SHOW}
className="mt-3"
/>
)} )}
</div> </div>
{bookmarkedProgresses.length > MAX_BOOKMARKS_TO_SHOW && (
<ShowAllButton
showAll={showAllBookmarks}
setShowAll={setShowAllBookmarks}
count={bookmarkedProgresses.length}
maxCount={MAX_BOOKMARKS_TO_SHOW}
className="mt-3"
/>
)}
</div> </div>
</div> </>
); );
} }
@ -229,3 +241,24 @@ function CardSkeleton(props: CardSkeletonProps) {
/> />
); );
} }
type StatsCardProps = {
title: string;
value: number;
isLoading?: boolean;
};
function StatsCard(props: StatsCardProps) {
const { title, value, isLoading = false } = props;
return (
<div className="flex flex-col gap-1 rounded-md border bg-white p-4 shadow-sm">
<h3 className="text-xs uppercase text-gray-500">{title}</h3>
{isLoading ? (
<CardSkeleton className="h-8" />
) : (
<span className="text-2xl font-medium text-black">{value}</span>
)}
</div>
);
}

@ -0,0 +1,11 @@
import { atom } from 'nanostores';
export type StreakResponse = {
count: number;
longestCount: number;
previousCount?: number | null;
firstVisitAt: Date;
lastVisitAt: Date;
};
export const $accountStreak = atom<StreakResponse | undefined>();
Loading…
Cancel
Save