user test results and testlist in profile

This commit is contained in:
David Katrinka 2025-12-01 11:38:17 +01:00
parent 01446071e5
commit cba24b05c9
15 changed files with 304 additions and 54 deletions

View File

View File

@ -10,7 +10,7 @@
href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap" href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<title>test-ai</title> <title>HoshiAI</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -1,7 +1,9 @@
import axios from "axios"; import axios from "axios";
const baseURL = import.meta.env.VITE_API_URL;
const axiosInstance = axios.create({ const axiosInstance = axios.create({
baseURL: "http://127.0.0.1:8000", baseURL,
timeout: 30 * 1000, timeout: 30 * 1000,
}); });

View File

@ -1,7 +1,5 @@
import axiosInstance from "./axiosInstance"; import axiosInstance from "./axiosInstance";
type GetQuestionsParams = { type GetQuestionsParams = {
page?: number; page?: number;
id?: number; id?: number;

View File

@ -28,8 +28,8 @@ export const getUserTestById = async (userTestId: number) => {
}; };
export const getUserTests = async () => { export const getUserTests = async () => {
const res = await axiosInstance.get<UserTestType[]>("/api/user-tests/me"); const res = await axiosInstance.get<{data: UserTestType[]}>("/api/user-tests/me");
return res.data; return res.data.data;
}; };
export const submitAnswer = async (data: SubmitAnswerPayload) => { export const submitAnswer = async (data: SubmitAnswerPayload) => {

View File

@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { Box, Button, Typography } from "@mui/material"; import { Box, Button, Typography } from "@mui/material";
import { arraysEqual } from "../../utils/functions";
interface Variant { interface Variant {
id: number; id: number;
@ -9,31 +10,67 @@ interface Variant {
interface LearningAnswersProps { interface LearningAnswersProps {
variants?: Variant[]; variants?: Variant[];
correctAnswers: number[] | string[]; correctAnswers: number[] | string[];
type: "single" | "text"; type: "single" | "multiple" | "text";
userAnswers?: number[];
} }
const LearningAnswers = ({ const LearningAnswers = ({
variants = [], variants = [],
correctAnswers = [], correctAnswers = [],
type, type,
userAnswers,
}: LearningAnswersProps) => { }: LearningAnswersProps) => {
const [showAnswer, setShowAnswer] = useState(false); 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") { if (type === "text") {
return ( return (
<Box sx={{ mt: 2 }}> <Box sx={{ mt: 2 }}>
{showAnswer && ( {hasAnswered ? (
isEmptyAnswer ? (
<Typography
variant="body1"
color="text.secondary"
sx={{ mt: 1, p: 1, border: "1px solid #ccc", borderRadius: 2 }}
>
You didnt answer
</Typography>
) : (
<Typography
variant="body1"
color="primary"
sx={{ mt: 1, p: 1, border: "1px solid #ccc", borderRadius: 2 }}
>
Your answer: {userAnswers.join(", ")}
</Typography>
)
) : null}
{(hasAnswered || showAnswer) && (
<Typography <Typography
variant="body1" variant="body1"
color="success.main" color="success.main"
sx={{ mt: 1, p: 1, border: "1px solid #ccc", borderRadius: 2 }} sx={{ mt: 1, p: 1, border: "1px solid #ccc", borderRadius: 2 }}
> >
Correct answer:{" "} Correct answer: {correctAnswers.join(", ")}
{Array.isArray(correctAnswers)
? correctAnswers.join(", ")
: correctAnswers}
</Typography> </Typography>
)} )}
{!hasAnswered && (
<Button <Button
variant="contained" variant="contained"
color="primary" color="primary"
@ -42,12 +79,30 @@ const LearningAnswers = ({
> >
Reveal Answer Reveal Answer
</Button> </Button>
)}
</Box> </Box>
); );
} }
return ( return (
<Box sx={{ mt: 2 }}> <Box sx={{ mt: 2 }}>
{hasAnswered ? (
isEmptyAnswer ? (
<Typography variant="body1" color="text.secondary" sx={{ mb: 1 }}>
You didnt answer
</Typography>
) : (
<Typography
variant="body1"
color={isCorrect ? "success.main" : "error.main"}
sx={{ mb: 1 }}
>
Your answer: {getUserAnswerText()} {" "}
{isCorrect ? "Correct" : "Incorrect"}
</Typography>
)
) : null}
{variants.map((variant) => ( {variants.map((variant) => (
<Box <Box
key={variant.id} key={variant.id}
@ -57,8 +112,12 @@ const LearningAnswers = ({
borderRadius: 2, borderRadius: 2,
border: "1px solid #ccc", border: "1px solid #ccc",
backgroundColor: backgroundColor:
showAnswer && (correctAnswers as number[]).includes(variant.id) showAnswer || hasAnswered
? (correctAnswers as number[]).includes(variant.id)
? "success.light" ? "success.light"
: normalizedUserAnswers.includes(variant.id)
? "error.light"
: "background.paper"
: "background.paper", : "background.paper",
}} }}
> >
@ -66,14 +125,16 @@ const LearningAnswers = ({
</Box> </Box>
))} ))}
{!hasAnswered && (
<Button <Button
variant="contained" variant="contained"
color="primary" color="primary"
sx={{ mt: 2 }} sx={{ mt: 2 }}
onClick={() => setShowAnswer(true)} onClick={() => setShowAnswer(!showAnswer)}
> >
Reveal Answer {showAnswer ? "Hide Answer" : "Reveal Answer"}
</Button> </Button>
)}
</Box> </Box>
); );
}; };

View File

@ -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 (
<Box
sx={{
border: "1px solid #ccc",
borderRadius: 2,
p: 2,
mb: 2,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Box>
<Typography variant="h6">{title}</Typography>
{test.closed_at && (
<Typography variant="body2" color="textSecondary">
Expires at: {formatDate(test.closed_at)}
</Typography>
)}
{test.is_completed && test.score !== undefined && (
<Typography variant="body2" color="textSecondary">
Score: {test.score}%
</Typography>
)}
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Chip label={statusLabel} color={statusColor} />
{test.is_available && !test.is_completed && (
<Button
component={Link}
to={`/tests/${test.id}`}
variant="contained"
color="primary"
>
Continue Test
</Button>
)}
{test.is_completed && (
<Button
component={Link}
to={`/tests/${test.id}`}
variant="contained"
color="success"
>
View Results
</Button>
)}
</Box>
</Box>
);
};
export default TestCard;

View File

@ -1,12 +1,15 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { completeUserTest } from "../../api/testApi"; import { completeUserTest } from "../../api/testApi";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
export const useCompleteTest = () => { export const useCompleteTest = () => {
const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: completeUserTest, mutationFn: completeUserTest,
onSuccess: (data) => { onSuccess: (data) => {
toast.success(data.message); toast.success(data.message);
queryClient.invalidateQueries({ queryKey: ["current-test"] });
queryClient.invalidateQueries({ queryKey: ["user-tests"] });
}, },
onError: (error: any) => { onError: (error: any) => {
toast.error(error.response?.data?.message); toast.error(error.response?.data?.message);

View File

@ -4,6 +4,6 @@ import { getUserTests } from "../../api/testApi";
export const useGetUserTests = () => { export const useGetUserTests = () => {
return useQuery({ return useQuery({
queryKey: ["user-tests"], queryKey: ["user-tests"],
queryFn: () => getUserTests, queryFn: () => getUserTests(),
}); });
}; };

View File

@ -1,13 +1,15 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { startTest } from "../../api/testApi"; import { startTest } from "../../api/testApi";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
export const useStartTest = () => { export const useStartTest = () => {
const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
return useMutation({ return useMutation({
mutationFn: startTest, mutationFn: startTest,
onSuccess: (data) => { onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["user-tests"] });
toast.success("Test Started"); toast.success("Test Started");
navigate(`/tests/${data.id}`); navigate(`/tests/${data.id}`);
}, },

View File

@ -1,13 +1,14 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { submitAnswer } from "../../api/testApi"; import { submitAnswer } from "../../api/testApi";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
export const useSubmitAnswer = () => { export const useSubmitAnswer = () => {
const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: submitAnswer, mutationFn: submitAnswer,
onSuccess: (data) => { onSuccess: (data) => {
toast.success(data.message); toast.success(data.message);
console.log(data); queryClient.invalidateQueries({ queryKey: ["current-test"] });
}, },
onError: (error: any) => { onError: (error: any) => {
toast.error(error.response?.data?.message || "Something went wrong"); toast.error(error.response?.data?.message || "Something went wrong");

View File

@ -34,7 +34,7 @@ const IndexPage = () => {
{error && <Typography color="error">{error}</Typography>} {error && <Typography color="error">{error}</Typography>}
{questions.map((q: QuestionType) => ( {questions.map((q: QuestionType) => (
<Question key={q.id} question={q} /> <Question key={q.id} question={q}/>
))} ))}
{meta && meta.last_page > 1 && ( {meta && meta.last_page > 1 && (

View File

@ -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 { useAuth } from "../../context/AuthContext";
import Container from "../../components/shared/Container"; import Container from "../../components/shared/Container";
import { useNavigate } from "react-router-dom"; 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 ProfilePage = () => {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const navigate = useNavigate(); 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 ( return (
<Container> <Container>
@ -27,7 +41,7 @@ const ProfilePage = () => {
</Button> </Button>
{user?.type === "admin" && ( {user?.type === "admin" && (
<Button <Button
sx={{marginLeft: "10px"}} sx={{ marginLeft: "10px" }}
variant="contained" variant="contained"
color="secondary" color="secondary"
onClick={() => navigate("/admin")} onClick={() => navigate("/admin")}
@ -36,6 +50,43 @@ const ProfilePage = () => {
</Button> </Button>
)} )}
</Box> </Box>
<Box sx={{ mt: 4 }}>
<Typography variant="h5" mb={2}>
Your Tests
</Typography>
<Tabs value={tabIndex} onChange={handleTabChange}>
<Tab label={`Active (${activeTests?.length || 0})`} />
<Tab label={`Completed (${completedTests?.length || 0})`} />
<Tab label={`Expired (${expiredTests?.length || 0})`} />
</Tabs>
<Box sx={{ mt: 2, display: "flex", flexDirection: "column", gap: 2 }}>
{isLoading && <Typography>Loading tests...</Typography>}
{error && (
<Typography color="error">Failed to load tests.</Typography>
)}
{tabIndex === 0 &&
(activeTests?.length ? (
activeTests.map((t) => <TestCard key={t.id} test={t} />)
) : (
<Typography>No active tests</Typography>
))}
{tabIndex === 1 &&
(completedTests?.length ? (
completedTests.map((t) => <TestCard key={t.id} test={t} />)
) : (
<Typography>No completed tests</Typography>
))}
{tabIndex === 2 &&
(expiredTests?.length ? (
expiredTests.map((t) => <TestCard key={t.id} test={t} />)
) : (
<Typography>No expired tests</Typography>
))}
</Box>
</Box>
</Container> </Container>
); );
}; };

View File

@ -6,6 +6,8 @@ import { Box, Button, Pagination, Typography } from "@mui/material";
import TestQuestion from "../../components/TestQuestion/TestQuestion"; import TestQuestion from "../../components/TestQuestion/TestQuestion";
import { useSubmitAnswer } from "../../hooks/Tests/useSubmitAnswer"; import { useSubmitAnswer } from "../../hooks/Tests/useSubmitAnswer";
import { useCompleteTest } from "../../hooks/Tests/useCompleteTest"; import { useCompleteTest } from "../../hooks/Tests/useCompleteTest";
import LearningAnswers from "../../components/Answers/Answers";
import { formatDate } from "../../utils/functions";
export const TestPage = () => { export const TestPage = () => {
const { id } = useParams(); const { id } = useParams();
@ -13,6 +15,7 @@ export const TestPage = () => {
const [currentQuestion, setCurrentQuestion] = useState(1); const [currentQuestion, setCurrentQuestion] = useState(1);
const submitAnswerMutation = useSubmitAnswer(); const submitAnswerMutation = useSubmitAnswer();
const completeTestMutation = useCompleteTest(); const completeTestMutation = useCompleteTest();
const allAnswered = test?.answers?.every((ans) => ans.answer !== null);
const handleCompleteTest = () => { const handleCompleteTest = () => {
if (test) completeTestMutation.mutate(test.id); if (test) completeTestMutation.mutate(test.id);
@ -54,15 +57,34 @@ export const TestPage = () => {
</Container> </Container>
); );
if (test.is_completed) if (test.is_completed) {
return ( return (
<Container> <Container>
<Typography variant="h6" color="textSecondary"> <Typography variant="h2" sx={{ mb: 3 }}>
You have already completed this test. Results
</Typography> </Typography>
<Typography variant="h5" color="secondary" sx={{ mb: 3 }}>
Your score: {test.score}%
</Typography>
{test?.answers?.map((ans) => (
<Box key={ans.question_id} sx={{ mb: 4 }}>
<Typography variant="h6">{ans.question.title}</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
{ans.question.description}
</Typography>
<LearningAnswers
type={ans.question.type as "single" | "multiple" | "text"}
variants={ans.question.variants}
correctAnswers={ans.question.correct_answers}
userAnswers={ans.answer as number[]}
/>
</Box>
))}
</Container> </Container>
); );
}
const questionsAndAnswers = test.answers; const questionsAndAnswers = test.answers;
const currentQA = questionsAndAnswers?.[currentQuestion - 1]; const currentQA = questionsAndAnswers?.[currentQuestion - 1];
@ -78,17 +100,7 @@ export const TestPage = () => {
> >
<Typography variant="h2">{test.test?.title ?? "User Test"}</Typography> <Typography variant="h2">{test.test?.title ?? "User Test"}</Typography>
<Typography variant="subtitle1"> <Typography variant="subtitle1">
Expires at:{" "} Expires at: {test.closed_at ? formatDate(test.closed_at) : "N/A"}
{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"}
</Typography> </Typography>
</Box> </Box>
{currentQA ? ( {currentQA ? (
@ -120,16 +132,27 @@ export const TestPage = () => {
page={currentQuestion} page={currentQuestion}
onChange={(_, page) => setCurrentQuestion(page)} onChange={(_, page) => setCurrentQuestion(page)}
/> />
{currentQuestion == questionsAndAnswers?.length && ( {currentQuestion == questionsAndAnswers?.length && (
<Button <Button
onClick={handleCompleteTest} onClick={handleCompleteTest}
variant="contained" variant="contained"
color="success" color="success"
disabled={!allAnswered}
sx={{ mt: 15, fontSize: "32px" }} sx={{ mt: 15, fontSize: "32px" }}
> >
Complete test Complete test
</Button> </Button>
)} )}
{currentQuestion == questionsAndAnswers?.length && !allAnswered && (
<Typography
variant="body2"
color="error"
sx={{ fontWeight: 500, textAlign: "center", mt: 2 }}
>
You must answer all questions before completing the test
</Typography>
)}
</Box> </Box>
</Container> </Container>
); );

29
src/utils/functions.ts Normal file
View File

@ -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}`;
};