From d74d5e530ee2fcffa3ff7acb6bea79eb239adc30 Mon Sep 17 00:00:00 2001 From: kushwahramkumar2003 Date: Tue, 1 Oct 2024 02:19:12 +0530 Subject: [PATCH 1/3] schema updated for store userProgress --- .../migration.sql | 23 +++++++++++++++++++ packages/db/prisma/schema.prisma | 17 ++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 packages/db/prisma/migrations/20240930174340_add_user_progress/migration.sql diff --git a/packages/db/prisma/migrations/20240930174340_add_user_progress/migration.sql b/packages/db/prisma/migrations/20240930174340_add_user_progress/migration.sql new file mode 100644 index 00000000..6d760987 --- /dev/null +++ b/packages/db/prisma/migrations/20240930174340_add_user_progress/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "UserProgress" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "trackId" TEXT NOT NULL, + "problemId" TEXT NOT NULL, + "completed" BOOLEAN NOT NULL DEFAULT false, + "lastVisited" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "UserProgress_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "UserProgress_userId_trackId_problemId_key" ON "UserProgress"("userId", "trackId", "problemId"); + +-- AddForeignKey +ALTER TABLE "UserProgress" ADD CONSTRAINT "UserProgress_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserProgress" ADD CONSTRAINT "UserProgress_trackId_fkey" FOREIGN KEY ("trackId") REFERENCES "Track"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserProgress" ADD CONSTRAINT "UserProgress_problemId_fkey" FOREIGN KEY ("problemId") REFERENCES "Problem"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 328b595b..bd5b3005 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -51,6 +51,7 @@ model User { accounts Account[] sessions Session[] quizScores QuizScore[] + progress UserProgress[] } model VerificationToken { @@ -72,6 +73,7 @@ model Track { hidden Boolean @default(false) cohort Int @default(0) createdAt DateTime @default(now()) + userProgress UserProgress[] } model Categories { @@ -98,6 +100,7 @@ model Problem { type ProblemType trackProblems TrackProblems[] quizScores QuizScore[] + userProgress UserProgress[] } model TrackProblems { track Track @relation(fields: [trackId], references: [id]) @@ -133,4 +136,18 @@ model QuizScore { problem Problem @relation(fields: [problemId], references: [id]) problemId String createdAt DateTime @default(now()) +} + +model UserProgress { + id String @id @default(uuid()) + userId String + trackId String + problemId String + completed Boolean @default(false) + lastVisited DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + track Track @relation(fields: [trackId], references: [id], onDelete: Cascade) + problem Problem @relation(fields: [problemId], references: [id], onDelete: Cascade) + + @@unique([userId, trackId, problemId]) } \ No newline at end of file From e63bd393bf9754ed6b9ce8e08e89aaea5396b90c Mon Sep 17 00:00:00 2001 From: kushwahramkumar2003 Date: Tue, 1 Oct 2024 02:20:18 +0530 Subject: [PATCH 2/3] new server action created for userProgress feature --- apps/web/components/utils.tsx | 67 ++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/apps/web/components/utils.tsx b/apps/web/components/utils.tsx index bdc0837f..bc33d055 100644 --- a/apps/web/components/utils.tsx +++ b/apps/web/components/utils.tsx @@ -1,7 +1,9 @@ "use server"; -import { MCQQuestion, Prisma, Problem, Track, TrackProblems } from "@prisma/client"; +import { MCQQuestion, Prisma, Problem, Track, TrackProblems, UserProgress } from "@prisma/client"; import db from "@repo/db/client"; import { cache } from "../../../packages/db/Cache"; +import { getServerSession } from "next-auth"; +import { authOptions } from "../lib/auth"; interface Tracks extends Track { problems: { problem: Problem }[]; @@ -478,3 +480,66 @@ export async function deleteCategory(categoryId: string) { }, }); } + +export async function setProgress(trackId: string, problemId: string, completed: boolean) { + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + throw new Error("User not authenticated"); + } + + const user = await db.user.findUnique({ + where: { email: session.user.email }, + }); + + if (!user) { + throw new Error("User not found"); + } + + await db.userProgress.upsert({ + where: { + userId_trackId_problemId: { + userId: user.id, + trackId, + problemId, + }, + }, + update: { + completed, + lastVisited: new Date(), + }, + create: { + userId: user.id, + trackId, + problemId, + completed, + lastVisited: new Date(), + }, + }); +} + +export const getUserProgress = async (): Promise => { + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return []; + } + + let userProgress: UserProgress[] = []; + + if (session?.user?.email) { + const user = await db.user.findUnique({ + where: { email: session.user.email }, + }); + + if (user) { + userProgress = await db.userProgress.findMany({ + where: { + userId: user.id, + }, + orderBy: { + lastVisited: "desc", + }, + }); + } + } + return userProgress; +}; From d6aa1f65b12fb623b1b5c9ce787c0cf8cc06910c Mon Sep 17 00:00:00 2001 From: kushwahramkumar2003 Date: Tue, 1 Oct 2024 02:22:38 +0530 Subject: [PATCH 3/3] track user progress feature added --- apps/web/components/LessonView.tsx | 6 ++ apps/web/components/TrackCard-2.tsx | 77 ++++++++------- apps/web/components/TrackPreview.tsx | 136 +++++++++++++++++++-------- apps/web/components/Tracks.tsx | 37 ++++---- apps/web/screens/Landing.tsx | 5 +- 5 files changed, 166 insertions(+), 95 deletions(-) diff --git a/apps/web/components/LessonView.tsx b/apps/web/components/LessonView.tsx index 5bc5d737..2284da70 100644 --- a/apps/web/components/LessonView.tsx +++ b/apps/web/components/LessonView.tsx @@ -5,6 +5,7 @@ import RedirectToLoginCard from "./RedirectToLoginCard"; import { getServerSession } from "next-auth"; import { authOptions } from "../lib/auth"; import { AppbarClient } from "./AppbarClient"; +import { setProgress } from "./utils"; export const LessonView = async ({ problem, @@ -34,6 +35,11 @@ export const LessonView = async ({ ); } + // Update progress when the lesson is viewed + if (session?.user) { + await setProgress(track.id, problem.id, true); + } + if (problem.type === "MCQ") { return ; } diff --git a/apps/web/components/TrackCard-2.tsx b/apps/web/components/TrackCard-2.tsx index 0238c882..59c8c7cf 100644 --- a/apps/web/components/TrackCard-2.tsx +++ b/apps/web/components/TrackCard-2.tsx @@ -1,8 +1,11 @@ -import { useEffect, useRef, useState } from "react"; +"use client"; + +import { useRef, useState } from "react"; import { motion, useAnimation } from "framer-motion"; -import { Track, Problem } from "@prisma/client"; +import { Track, Problem, UserProgress } from "@prisma/client"; import { TrackPreview } from "./TrackPreview"; import { formatDistanceToNow } from "date-fns"; +import { useSession } from "next-auth/react"; interface TrackCardProps extends Track { problems: Problem[]; @@ -14,37 +17,26 @@ interface TrackCardProps extends Track { }[]; } -export function TrackCard2({ track }: { track: TrackCardProps }) { +export function TrackCard2({ track, userProgress }: { track: TrackCardProps; userProgress: UserProgress[] }) { const controls = useAnimation(); const ref = useRef(null); const [showPreview, setShowPreview] = useState(false); - - useEffect(() => { - const observer = new IntersectionObserver( - ([entry]) => { - if (entry && entry.isIntersecting) { - controls.start("visible"); - } - }, - { threshold: 0.3 } - ); - - if (ref.current) { - observer.observe(ref.current); - } - - return () => { - if (ref.current) { - observer.unobserve(ref.current); - } - }; - }, [controls]); + const { data: session } = useSession(); const variants = { - hidden: { opacity: 0 }, + // hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: 0.1, ease: "easeOut" } }, }; + const completedProblems = userProgress.filter((p) => p.completed).length; + const progressPercentage = (completedProblems / track.problems.length) * 100; + const isCompleted = progressPercentage === 100; + + const lastVisitedProblem = + userProgress.length > 0 + ? userProgress.reduce((prev, current) => (prev.lastVisited > current.lastVisited ? prev : current)) + : null; + return ( <> setShowPreview(true)} > - {track.title} -
+ {track.title} +
-

{track.title}

+

{track.title}

{track.categories.map((item) => (

{item.category.category}

))}
-
-

+

+

{track.problems.length} Chapters

-

+

{formatDistanceToNow(new Date(track.createdAt), { addSuffix: true })}

+ {(session?.user || userProgress.length > 0) && ( +
+
+
+
+ + {progressPercentage.toFixed(0)}% + +
+ )}
- + ); } diff --git a/apps/web/components/TrackPreview.tsx b/apps/web/components/TrackPreview.tsx index d3b85e0d..8d47ee05 100644 --- a/apps/web/components/TrackPreview.tsx +++ b/apps/web/components/TrackPreview.tsx @@ -1,16 +1,26 @@ "use client"; + import React, { useState, useEffect } from "react"; import Link from "next/link"; -import { Button } from "../../../packages/ui/src/shad/ui/button"; -import { Dialog, DialogContent } from "../../../packages/ui/src/shad/ui/dailog"; -import { ArrowRight } from "lucide-react"; +import { ArrowRight, Play, Redo, CheckCircle } from "lucide-react"; import { formatDistanceToNow } from "date-fns"; +import { Track, Problem, UserProgress, Categories } from "@prisma/client"; +import { useSession } from "next-auth/react"; +import { Button, Dialog, DialogContent } from "@repo/ui"; -type TrackPreviewProps = { +interface TrackPreviewProps { showPreview: boolean; setShowPreview: (val: boolean) => void; - track: any; -}; + track: Track & { + problems: Problem[]; + categories: { + category: Categories; + }[]; + }; + isCompleted: boolean; + lastVisitedProblem: UserProgress | null; + userProgress: UserProgress[]; +} const truncateDescription = (text: string, wordLimit: number) => { const words = text.split(" "); @@ -20,74 +30,120 @@ const truncateDescription = (text: string, wordLimit: number) => { return text; }; -export function TrackPreview({ showPreview, setShowPreview, track }: TrackPreviewProps) { +export function TrackPreview({ + showPreview, + setShowPreview, + track, + isCompleted, + lastVisitedProblem, + userProgress, +}: TrackPreviewProps) { const [isMediumOrLarger, setIsMediumOrLarger] = useState(false); - - const updateScreenSize = () => { - setIsMediumOrLarger(window.innerWidth >= 768); - }; + const { data: session } = useSession(); + const [completedProblems, setCompletedProblems] = useState([]); + const [lastVisited, setLastVisited] = useState(null); useEffect(() => { - updateScreenSize(); // Set the initial state - window.addEventListener("resize", updateScreenSize); // Add resize listener + const updateScreenSize = () => { + setIsMediumOrLarger(window.innerWidth >= 768); + }; + + updateScreenSize(); + window.addEventListener("resize", updateScreenSize); return () => { - window.removeEventListener("resize", updateScreenSize); // Cleanup listener on unmount + window.removeEventListener("resize", updateScreenSize); }; }, []); + useEffect(() => { + if (session?.user) { + setCompletedProblems(userProgress.filter((p) => p.completed).map((p) => p.problemId)); + setLastVisited(lastVisitedProblem?.problemId || null); + } + }, [session, track.id, userProgress, lastVisitedProblem]); + const truncatedDescription = isMediumOrLarger ? track.description : truncateDescription(track.description, 15); + const progressPercentage = (completedProblems.length / track.problems.length) * 100; + return ( setShowPreview(false)}> -
- -
+
+ {track.title} +
-

{track.title}

+

{track.title}

- {track.categories.map((item: any, idx: number) => ( + {track.categories.map((item) => (

- {item.category.category}{" "} + {item.category.category}

))}
-

{truncatedDescription}

+

{truncatedDescription}

-
-
-

+

+
+

{track.problems.length} Chapters

-

+

{formatDistanceToNow(new Date(track.createdAt), { addSuffix: true })}

-
- {track.problems.map((topic: any, idx: number) => ( - -
+
+ {track.problems.map((topic) => ( + +
{topic.title} - + {completedProblems.includes(topic.id) ? ( + + ) : ( + + )}
))}
- - - +
+
+
+
+
+ + {progressPercentage.toFixed(0)}% Complete + +
+
+ + + + {lastVisited && lastVisited !== track.problems[0]?.id && ( + + + + )} + {isCompleted && ( + + )} +
+
diff --git a/apps/web/components/Tracks.tsx b/apps/web/components/Tracks.tsx index d9af482c..62704ec5 100644 --- a/apps/web/components/Tracks.tsx +++ b/apps/web/components/Tracks.tsx @@ -2,7 +2,7 @@ import { TrackCard2 } from "./TrackCard-2"; import { category } from "@repo/store"; import { useEffect, useState } from "react"; -import { Track, Problem } from "@prisma/client"; +import { Track, Problem, Categories, UserProgress } from "@prisma/client"; import { useRecoilState } from "recoil"; import { Select, @@ -36,7 +36,8 @@ export interface TrackPros extends Track { interface TracksWithCategoriesProps { tracks: TrackPros[]; - categories: { id: string; category: string }[]; + categories: Categories[]; + userProgress: UserProgress[]; } enum CohortGroup { @@ -45,7 +46,7 @@ enum CohortGroup { Three = 3, } -export const Tracks = ({ tracks, categories }: TracksWithCategoriesProps) => { +export const Tracks = ({ tracks, categories, userProgress }: TracksWithCategoriesProps) => { const [selectedCategory, setSelectedCategory] = useRecoilState(category); const [filteredTracks, setFilteredTracks] = useState(tracks); const [visibleTracks, setVisibleTracks] = useState([]); @@ -128,11 +129,11 @@ export const Tracks = ({ tracks, categories }: TracksWithCategoriesProps) => { initial={{ y: -20, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ duration: 0.5, ease: "easeInOut", type: "spring", damping: 10, delay: 0.5 }} - className="flex max-w-5xl flex-col gap-4 w-full mx-auto p-4" + className="mx-auto flex w-full max-w-5xl flex-col gap-4 p-4" id="tracks" > -
-
+
+
- +
-
+
{/* Filter by Categories */} -
+