From cba24b05c92c55d683579e609876ec8e326a67a5 Mon Sep 17 00:00:00 2001 From: David Katrinka Date: Mon, 1 Dec 2025 11:38:17 +0100 Subject: [PATCH] user test results and testlist in profile --- src/.env => .env | 0 index.html | 2 +- src/api/axiosInstance.ts | 4 +- src/api/questionsApi.ts | 2 - src/api/testApi.ts | 4 +- src/components/Answers/Answers.tsx | 109 ++++++++++++++++++++------ src/components/TestCard/TestCard.tsx | 80 +++++++++++++++++++ src/hooks/Tests/useCompleteTest.ts | 7 +- src/hooks/Tests/useGetUserTests.ts | 2 +- src/hooks/Tests/useStartTest.ts | 4 +- src/hooks/Tests/useSubmitAnswer.ts | 5 +- src/pages/IndexPage/IndexPage.tsx | 2 +- src/pages/ProfilePage/Profilepage.tsx | 55 ++++++++++++- src/pages/TestPage/TestPage.tsx | 53 +++++++++---- src/utils/functions.ts | 29 +++++++ 15 files changed, 304 insertions(+), 54 deletions(-) rename src/.env => .env (100%) create mode 100644 src/components/TestCard/TestCard.tsx create mode 100644 src/utils/functions.ts diff --git a/src/.env b/.env similarity index 100% rename from src/.env rename to .env diff --git a/index.html b/index.html index 68de6bf..515502d 100644 --- a/index.html +++ b/index.html @@ -10,7 +10,7 @@ href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet" /> - test-ai + HoshiAI
diff --git a/src/api/axiosInstance.ts b/src/api/axiosInstance.ts index 686a88b..6a38bbd 100644 --- a/src/api/axiosInstance.ts +++ b/src/api/axiosInstance.ts @@ -1,7 +1,9 @@ import axios from "axios"; +const baseURL = import.meta.env.VITE_API_URL; + const axiosInstance = axios.create({ - baseURL: "http://127.0.0.1:8000", + baseURL, timeout: 30 * 1000, }); diff --git a/src/api/questionsApi.ts b/src/api/questionsApi.ts index b9649b1..e0a67f2 100644 --- a/src/api/questionsApi.ts +++ b/src/api/questionsApi.ts @@ -1,7 +1,5 @@ import axiosInstance from "./axiosInstance"; - - type GetQuestionsParams = { page?: number; id?: number; diff --git a/src/api/testApi.ts b/src/api/testApi.ts index 974696e..489d1e6 100644 --- a/src/api/testApi.ts +++ b/src/api/testApi.ts @@ -28,8 +28,8 @@ export const getUserTestById = async (userTestId: number) => { }; export const getUserTests = async () => { - const res = await axiosInstance.get("/api/user-tests/me"); - return res.data; + const res = await axiosInstance.get<{data: UserTestType[]}>("/api/user-tests/me"); + return res.data.data; }; export const submitAnswer = async (data: SubmitAnswerPayload) => { diff --git a/src/components/Answers/Answers.tsx b/src/components/Answers/Answers.tsx index 63f64d8..2c978f5 100644 --- a/src/components/Answers/Answers.tsx +++ b/src/components/Answers/Answers.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { Box, Button, Typography } from "@mui/material"; +import { arraysEqual } from "../../utils/functions"; interface Variant { id: number; @@ -9,45 +10,99 @@ interface Variant { interface LearningAnswersProps { variants?: Variant[]; correctAnswers: number[] | string[]; - type: "single" | "text"; + type: "single" | "multiple" | "text"; + userAnswers?: number[]; } const LearningAnswers = ({ variants = [], correctAnswers = [], type, + userAnswers, }: LearningAnswersProps) => { const [showAnswer, setShowAnswer] = useState(false); + const hasAnswered = userAnswers !== null && userAnswers !== undefined; + const isEmptyAnswer = hasAnswered && userAnswers.length === 0; + const isCorrect = hasAnswered + ? arraysEqual(correctAnswers as number[], userAnswers as number[]) + : false; + const normalizedUserAnswers = userAnswers ?? []; + + const getUserAnswerText = () => { + if (!normalizedUserAnswers.length) return ""; + const selectedVariants = variants.filter((v) => + normalizedUserAnswers.includes(v.id) + ); + return selectedVariants.map((v) => v.text).join(", "); + }; + if (type === "text") { return ( - {showAnswer && ( + {hasAnswered ? ( + isEmptyAnswer ? ( + + You didn’t answer + + ) : ( + + Your answer: {userAnswers.join(", ")} + + ) + ) : null} + + {(hasAnswered || showAnswer) && ( - Correct answer:{" "} - {Array.isArray(correctAnswers) - ? correctAnswers.join(", ") - : correctAnswers} + Correct answer: {correctAnswers.join(", ")} )} - + + {!hasAnswered && ( + + )} ); } return ( + {hasAnswered ? ( + isEmptyAnswer ? ( + + You didn’t answer + + ) : ( + + Your answer: {getUserAnswerText()} —{" "} + {isCorrect ? "Correct" : "Incorrect"} + + ) + ) : null} + {variants.map((variant) => ( @@ -66,14 +125,16 @@ const LearningAnswers = ({ ))} - + {!hasAnswered && ( + + )} ); }; diff --git a/src/components/TestCard/TestCard.tsx b/src/components/TestCard/TestCard.tsx new file mode 100644 index 0000000..7e4661c --- /dev/null +++ b/src/components/TestCard/TestCard.tsx @@ -0,0 +1,80 @@ +import { Box, Button, Chip, Typography } from "@mui/material"; +import { Link } from "react-router-dom"; +import { formatDate } from "../../utils/functions"; +import type { UserTestType } from "../shared/types/TestTypes"; + +interface TestCardProps { + test: UserTestType; +} + +const TestCard = ({ test }: TestCardProps) => { + const title = test.test?.title || "User Test"; + + let statusLabel = ""; + let statusColor: "primary" | "success" | "error" = "primary"; + + if (test.is_completed) { + statusLabel = "Completed"; + statusColor = "success"; + } else if (!test.is_available) { + statusLabel = "Expired"; + statusColor = "error"; + } else { + statusLabel = "Active"; + statusColor = "primary"; + } + + return ( + + + {title} + {test.closed_at && ( + + Expires at: {formatDate(test.closed_at)} + + )} + {test.is_completed && test.score !== undefined && ( + + Score: {test.score}% + + )} + + + + + {test.is_available && !test.is_completed && ( + + )} + {test.is_completed && ( + + )} + + + ); +}; + +export default TestCard; diff --git a/src/hooks/Tests/useCompleteTest.ts b/src/hooks/Tests/useCompleteTest.ts index 940dc19..dd49599 100644 --- a/src/hooks/Tests/useCompleteTest.ts +++ b/src/hooks/Tests/useCompleteTest.ts @@ -1,12 +1,15 @@ -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { completeUserTest } from "../../api/testApi"; import { toast } from "react-toastify"; export const useCompleteTest = () => { + const queryClient = useQueryClient(); return useMutation({ - mutationFn: completeUserTest, + mutationFn: completeUserTest, onSuccess: (data) => { toast.success(data.message); + queryClient.invalidateQueries({ queryKey: ["current-test"] }); + queryClient.invalidateQueries({ queryKey: ["user-tests"] }); }, onError: (error: any) => { toast.error(error.response?.data?.message); diff --git a/src/hooks/Tests/useGetUserTests.ts b/src/hooks/Tests/useGetUserTests.ts index 7371ce3..417e8db 100644 --- a/src/hooks/Tests/useGetUserTests.ts +++ b/src/hooks/Tests/useGetUserTests.ts @@ -4,6 +4,6 @@ import { getUserTests } from "../../api/testApi"; export const useGetUserTests = () => { return useQuery({ queryKey: ["user-tests"], - queryFn: () => getUserTests, + queryFn: () => getUserTests(), }); }; diff --git a/src/hooks/Tests/useStartTest.ts b/src/hooks/Tests/useStartTest.ts index a534e89..bfd7457 100644 --- a/src/hooks/Tests/useStartTest.ts +++ b/src/hooks/Tests/useStartTest.ts @@ -1,13 +1,15 @@ -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { startTest } from "../../api/testApi"; import { toast } from "react-toastify"; import { useNavigate } from "react-router-dom"; export const useStartTest = () => { + const queryClient = useQueryClient(); const navigate = useNavigate(); return useMutation({ mutationFn: startTest, onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ["user-tests"] }); toast.success("Test Started"); navigate(`/tests/${data.id}`); }, diff --git a/src/hooks/Tests/useSubmitAnswer.ts b/src/hooks/Tests/useSubmitAnswer.ts index 1410811..ed585f0 100644 --- a/src/hooks/Tests/useSubmitAnswer.ts +++ b/src/hooks/Tests/useSubmitAnswer.ts @@ -1,13 +1,14 @@ -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { submitAnswer } from "../../api/testApi"; import { toast } from "react-toastify"; export const useSubmitAnswer = () => { + const queryClient = useQueryClient(); return useMutation({ mutationFn: submitAnswer, onSuccess: (data) => { toast.success(data.message); - console.log(data); + queryClient.invalidateQueries({ queryKey: ["current-test"] }); }, onError: (error: any) => { toast.error(error.response?.data?.message || "Something went wrong"); diff --git a/src/pages/IndexPage/IndexPage.tsx b/src/pages/IndexPage/IndexPage.tsx index 8bc075b..616ca3d 100644 --- a/src/pages/IndexPage/IndexPage.tsx +++ b/src/pages/IndexPage/IndexPage.tsx @@ -34,7 +34,7 @@ const IndexPage = () => { {error && {error}} {questions.map((q: QuestionType) => ( - + ))} {meta && meta.last_page > 1 && ( diff --git a/src/pages/ProfilePage/Profilepage.tsx b/src/pages/ProfilePage/Profilepage.tsx index deb835f..cd7436a 100644 --- a/src/pages/ProfilePage/Profilepage.tsx +++ b/src/pages/ProfilePage/Profilepage.tsx @@ -1,11 +1,25 @@ -import { Box, Button, Typography } from "@mui/material"; +import { Box, Button, Tab, Tabs, Typography } from "@mui/material"; import { useAuth } from "../../context/AuthContext"; import Container from "../../components/shared/Container"; import { useNavigate } from "react-router-dom"; +import { useGetUserTests } from "../../hooks/Tests/useGetUserTests"; +import TestCard from "../../components/TestCard/TestCard"; +import { useState } from "react"; const ProfilePage = () => { const { user, logout } = useAuth(); const navigate = useNavigate(); + const { data: tests, isLoading, error } = useGetUserTests(); + + const [tabIndex, setTabIndex] = useState(0); + + const activeTests = tests?.filter((t) => t.is_available && !t.is_completed); + const completedTests = tests?.filter((t) => t.is_completed); + const expiredTests = tests?.filter((t) => !t.is_available && !t.is_completed); + + const handleTabChange = (_: React.SyntheticEvent, newValue: number) => { + setTabIndex(newValue); + }; return ( @@ -27,7 +41,7 @@ const ProfilePage = () => { {user?.type === "admin" && ( )} + + + Your Tests + + + + + + + + + + {isLoading && Loading tests...} + {error && ( + Failed to load tests. + )} + + {tabIndex === 0 && + (activeTests?.length ? ( + activeTests.map((t) => ) + ) : ( + No active tests + ))} + {tabIndex === 1 && + (completedTests?.length ? ( + completedTests.map((t) => ) + ) : ( + No completed tests + ))} + {tabIndex === 2 && + (expiredTests?.length ? ( + expiredTests.map((t) => ) + ) : ( + No expired tests + ))} + + ); }; diff --git a/src/pages/TestPage/TestPage.tsx b/src/pages/TestPage/TestPage.tsx index cc32106..060bc4a 100644 --- a/src/pages/TestPage/TestPage.tsx +++ b/src/pages/TestPage/TestPage.tsx @@ -6,6 +6,8 @@ import { Box, Button, Pagination, Typography } from "@mui/material"; import TestQuestion from "../../components/TestQuestion/TestQuestion"; import { useSubmitAnswer } from "../../hooks/Tests/useSubmitAnswer"; import { useCompleteTest } from "../../hooks/Tests/useCompleteTest"; +import LearningAnswers from "../../components/Answers/Answers"; +import { formatDate } from "../../utils/functions"; export const TestPage = () => { const { id } = useParams(); @@ -13,6 +15,7 @@ export const TestPage = () => { const [currentQuestion, setCurrentQuestion] = useState(1); const submitAnswerMutation = useSubmitAnswer(); const completeTestMutation = useCompleteTest(); + const allAnswered = test?.answers?.every((ans) => ans.answer !== null); const handleCompleteTest = () => { if (test) completeTestMutation.mutate(test.id); @@ -54,15 +57,34 @@ export const TestPage = () => { ); - if (test.is_completed) + if (test.is_completed) { return ( - - You have already completed this test. + + Results + + Your score: {test.score}% + + + {test?.answers?.map((ans) => ( + + {ans.question.title} + + {ans.question.description} + + + + + ))} ); - + } const questionsAndAnswers = test.answers; const currentQA = questionsAndAnswers?.[currentQuestion - 1]; @@ -78,17 +100,7 @@ export const TestPage = () => { > {test.test?.title ?? "User Test"} - Expires at:{" "} - {test.closed_at - ? new Date(test.closed_at).toLocaleString("en-GB", { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - hour12: false, - }) - : "N/A"} + Expires at: {test.closed_at ? formatDate(test.closed_at) : "N/A"} {currentQA ? ( @@ -120,16 +132,27 @@ export const TestPage = () => { page={currentQuestion} onChange={(_, page) => setCurrentQuestion(page)} /> + {currentQuestion == questionsAndAnswers?.length && ( )} + {currentQuestion == questionsAndAnswers?.length && !allAnswered && ( + + You must answer all questions before completing the test + + )} ); diff --git a/src/utils/functions.ts b/src/utils/functions.ts new file mode 100644 index 0000000..fdec8ba --- /dev/null +++ b/src/utils/functions.ts @@ -0,0 +1,29 @@ +export function arraysEqual(a: number[], b: number[]): boolean { + if (a === b) return true; + if (!a || !b) return false; + if (a.length !== b.length) return false; + + a = [...a].sort((x, y) => x - y); + b = [...b].sort((x, y) => x - y); + + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +export const formatDate = (dateString: string | undefined) => { + if (!dateString) return "N/A"; + + const date = new Date(dateString); + + const pad = (num: number) => num.toString().padStart(2, "0"); + + const day = pad(date.getDate()); + const month = pad(date.getMonth() + 1); + const year = date.getFullYear(); + const hours = pad(date.getHours()); + const minutes = pad(date.getMinutes()); + + return `${day}/${month}/${year} ${hours}:${minutes}`; +};