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 { httpGet } from '../../lib/http';
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 { StreakDay } from './StreakDay';
import {
@ -11,6 +11,7 @@ import {
} from '../../stores/page.ts';
import { useStore } from '@nanostores/react';
import { cn } from '../../lib/classname.ts';
import { $accountStreak } from '../../stores/streak.ts';
type StreakResponse = {
count: number;
@ -27,12 +28,7 @@ export function AccountStreak(props: AccountStreakProps) {
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 accountStreak = useStore($accountStreak);
const [showDropdown, setShowDropdown] = useState(false);
const $roadmapsDropdownOpen = useStore(roadmapsDropdownOpen);
@ -49,6 +45,11 @@ export function AccountStreak(props: AccountStreakProps) {
return;
}
if (accountStreak) {
setIsLoading(false);
return;
}
setIsLoading(true);
const { response, error } = await httpGet<StreakResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-streak`,
@ -60,7 +61,7 @@ export function AccountStreak(props: AccountStreakProps) {
return;
}
setAccountStreak(response);
$accountStreak.set(response);
setIsLoading(false);
};
@ -76,7 +77,7 @@ export function AccountStreak(props: AccountStreakProps) {
return null;
}
let { count: currentCount } = accountStreak;
let { count: currentCount = 0 } = accountStreak || {};
const previousCount =
accountStreak?.previousCount || accountStreak?.count || 0;
@ -110,7 +111,7 @@ export function AccountStreak(props: AccountStreakProps) {
ref={dropdownRef}
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">
<p>
Current Streak
@ -180,7 +181,7 @@ export function AccountStreak(props: AccountStreakProps) {
</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!
</p>
</div>

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

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