Skip to content

Commit

Permalink
Merge pull request CS3219-AY2425S1#62 from Jiayan-Lim/questionHistory-ui
Browse files Browse the repository at this point in the history
Implement code viewer for user’s past matching session
  • Loading branch information
Jiayan-Lim authored Nov 7, 2024
2 parents cd08441 + 456b08f commit c0e9fe0
Show file tree
Hide file tree
Showing 8 changed files with 336 additions and 57 deletions.
2 changes: 2 additions & 0 deletions backend/user-service/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,7 @@ Records a question attempt for a user, either creating a new record or updating
"questionId": "18",
"timeSpent": 120, // Time spent on the question in seconds
"code": "function solve() { ... }" // User's code submission for the attempt
"language": "javascript"
}
- **Response Format** (JSON):
- **Code**: 200
Expand Down Expand Up @@ -519,6 +520,7 @@ Retrieve all details for a particular question attempt made by a user.
"attemptCount": 3,
"attemptTime": 30,
"code": "function add(x, y){\r\n return x + y;\r\n}\r\n\r\nfunction substract(x, y) {\r\n return x - y;\r\n}\r\n\r\nconst addition = add(1, 2);\r\n",
"language": "javascript"
"question": {
"id": 19,
"title": "Chalkboard XOR Game",
Expand Down
6 changes: 3 additions & 3 deletions backend/user-service/controller/user-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,8 @@ export async function getUserHistory(req, res) {
export async function addQuestionAttempt(req, res) {
try {
const userId = req.params.userId;
const { questionId, timeSpent, code} = req.body;

const { questionId, timeSpent, code, language} = req.body;
console.log("language received: ", language)
const parsedId = Number(questionId);
console.log(parsedId);

Expand All @@ -273,7 +273,7 @@ export async function addQuestionAttempt(req, res) {

console.log("Found question:", question);
// Add or update the question attempt in the user's question history
const updated = await _addOrUpdateQuestionHistory(userId, question._id, timeSpent, code);
const updated = await _addOrUpdateQuestionHistory(userId, question._id, timeSpent, code, language);

if (updated) {
return res.status(200).json({ message: "Question history updated successfully." });
Expand Down
4 changes: 4 additions & 0 deletions backend/user-service/model/questionHistory.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ const QuestionHistorySchema = new mongoose.Schema({
code: {
type: String,
required: true,
},
language: {
type: String,
required: true,
}
});

Expand Down
7 changes: 5 additions & 2 deletions backend/user-service/model/repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,9 @@ export async function getTotalQuestionsAvailable() {
return response.data.length;
}

export async function addOrUpdateQuestionHistory(userId, questionId, timeSpent, code) {
export async function addOrUpdateQuestionHistory(userId, questionId, timeSpent, code, language) {
try {
console.log("Received data in addOrUpdateQuestionHistory:", { userId, questionId, timeSpent, code});
console.log("Received data in addOrUpdateQuestionHistory:", { userId, questionId, timeSpent, code, language});

// Try to find an existing record
console.log("Attempting to find existing history with userId and questionId...");
Expand All @@ -180,6 +180,7 @@ export async function addOrUpdateQuestionHistory(userId, questionId, timeSpent,
existingHistory.attemptTime += timeSpent;
existingHistory.attemptDate = new Date();
existingHistory.code = code;
existingHistory.language = language;

// Try to save the updated document
await existingHistory.save();
Expand All @@ -196,6 +197,7 @@ export async function addOrUpdateQuestionHistory(userId, questionId, timeSpent,
attemptCount: 1,
attemptTime: timeSpent,
code: code,
language: language
});

// Try to save the new document
Expand Down Expand Up @@ -229,6 +231,7 @@ export async function findQuestionAttemptDetails(userId, questionId) {
attemptCount: attempt.attemptCount,
attemptTime: attempt.attemptTime,
code: attempt.code,
language: attempt.language,
question: questionDetails ? {
id: questionDetails.id,
title: questionDetails.title,
Expand Down
217 changes: 217 additions & 0 deletions frontend/app/(authenticated)/profile/question-history/code/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
"use client";

import { useEffect, useRef, useState } from 'react';
import { Copy, Flag, MessageSquareText } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Toaster } from "@/components/ui/sonner"
import { useSearchParams } from 'next/navigation';
import { getCookie, setCookie } from '@/app/utils/cookie-manager';
import { ScrollArea } from "@/components/ui/scroll-area"
import { Badge } from '@/components/ui/badge';
import Editor from "@monaco-editor/react";
import { toast } from "sonner"
import Markdown from 'react-markdown'

type Question = {
id: number;
title: string;
complexity: string;
category: string[];
description: string;
link: string;
}

function getTimeAgo(attemptDate: Date | null) {
const now = new Date();

const diffInMs = now - attemptDate; // Difference in milliseconds
const diffInMinutes = Math.floor(diffInMs / (1000 * 60));
const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60));
const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24));

if (diffInDays > 0) {
return `${diffInDays} day${diffInDays > 1 ? 's' : ''} ago`;
} else if (diffInHours > 0) {
return `${diffInHours} hour${diffInHours > 1 ? 's' : ''} ago`;
} else if (diffInMinutes > 0) {
return `${diffInMinutes} minute${diffInMinutes > 1 ? 's' : ''} ago`;
} else {
return "1 minute ago";
}
}

export default function CodeViewer() {
const searchParams = useSearchParams();
const questionId = searchParams.get("questionId");
const [attemptDate, setAttemptDate] = useState<Date | null>(null);
const [questionDetails, setQuestionDetails] = useState<Question>({
"id": 1,
"title": "Question Title",
"complexity": "Easy",
"category": ["Arrays", "Algorithms"],
"description": "question details",
"link": ""
});
const [code, setCode] = useState("");
const [language, setLanguage] = useState("javascript")
const userId = useRef<string | null>(null);

useEffect(() => {
const fetchAttemptDetails = async () => {
try {
userId.current = getCookie('userId');

if (!userId.current) {
// Call the API to get user id
const response = await fetch(`${process.env.NEXT_PUBLIC_USER_API_AUTH_URL}/verify-token`, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getCookie('token')}`,
},
});

const data = (await response.json()).data;
setCookie('userId', data.id, { 'max-age': '86400', 'path': '/', 'SameSite': 'Strict' });
}

console.log("In question history page: call api to fetch user past atttempted code")
const response = await fetch(`${process.env.NEXT_PUBLIC_USER_API_HISTORY_URL}/${userId.current}/question/${questionId}`, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getCookie('token')}`,
},
}
);

const data = await response.json();
if (!response.ok) {
console.error("Error:", data.message);
throw Error("Error happen when calling backend API");
}

setQuestionDetails({
id: data.question.id,
title: data.question.title,
complexity: data.question.complexity,
category: data.question.category.sort((a: string, b: string) =>
a.localeCompare(b)
),
description: data.question.description,
link: data.question.link,
})
setAttemptDate(new Date(data.attemptDate));
setCode(JSON.parse(data.code))
setLanguage(data.language)
} catch (err) {
console.error(err.message);
toast.dismiss();
toast.error("Failed to load the code. Please try again later.");
}
};

fetchAttemptDetails();
}, [questionId]);

const handleEditorDidMount = (editor) => {
editor.getDomNode().classList.add("my-custom-editor");

// Insert scoped tooltip styles
const style = document.createElement("style");
style.textContent = `
.my-custom-editor .monaco-tooltip {
z-index: 1000 !important;
position: absolute;
}
`;
document.head.appendChild(style);

// Cleanup on component unmount
return () => {
document.head.removeChild(style);
};
};

const copyToClipboard = () => {
navigator.clipboard.writeText(code).then(() => {
toast.dismiss();
toast.success('Code copied to clipboard!');
});
};

return (
<div className="flex gap-4 min-h-screen px-10 pt-24 pb-5">
{/* Left Panel: Question Details */}
<ScrollArea className="w-1/2 p-4 border rounded-lg shadow bg-white">
<h3 className="text-3xl font-serif font-large tracking-tight">
{questionDetails?.title || ""}
</h3>
<div className="flex items-center gap-10 mt-8">
<div className="flex items-center gap-2">
<Flag className="h-4 w-4 text-icon" />
<Badge
variant={
(questionDetails?.complexity || "").toLowerCase() as BadgeProps["variant"]
}
>
{questionDetails?.complexity || ""}
</Badge>
</div>
<div className="flex items-center gap-2">
<MessageSquareText className="h-4 w-4 text-icon" />
{questionDetails?.category?.length > 0 &&
questionDetails?.category.map((category) => (
<Badge
key={category}
variant="category"
className="uppercase text-category-text bg-category-bg"
>
{category}
</Badge>
))}
</div>
</div>
<p className="mt-8 text-l text-foreground">
{questionDetails?.description || ""}
</p>
<Markdown className="mt-8 prose prose-zinc prose-code:bg-zinc-200 prose-code:px-1 prose-code:rounded prose-code:prose-pre:bg-inherit text-sm text-foreground proportional-nums">
{questionDetails?.description || ""}
</Markdown>
</ScrollArea>

{/* Right Panel: Code Display */}
<div className="w-1/2 flex flex-col border rounded-lg shadow bg-gray-50">
<div className="flex justify-between items-center bg-gray-100 px-5 py-1 border-b">
<div className="text-gray-500 text-sm">
{code.split('\n').length} lines {attemptDate ? ` • Attempted ${getTimeAgo(attemptDate)}` : ""}
</div>
<Button onClick={copyToClipboard} className='flex items-center gap-1 bg-gray-100 border border-gray-300 rounded text-gray-700 hover:bg-gray-200 hover:border-gray-400'>
<Copy className="h-4 w-4" /> Copy
</Button>
</div>

{/* Editor Wrapper */}
<div className="flex-grow">
<Editor
height="100%"
language={language}
value={code}
options={{
readOnly: true,
minimap: { enabled: false },
lineNumbers: "on",
fontSize: 14,
padding: { top: 10, bottom: 10 },
scrollBeyondLastLine: false,
scrollbar: {
vertical: "auto",
horizontal: "auto",
},
}}
/>
</div>
</div>

<Toaster position="top-center" />
</div>
);
}
45 changes: 42 additions & 3 deletions frontend/app/(authenticated)/profile/question-history/columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { ColumnDef } from "@tanstack/react-table"
import { AlignLeft, ArrowUpDown } from "lucide-react"
import { AlignLeft, ArrowUpDown, MoreHorizontal } from "lucide-react"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import Link from "next/link"

export type QuestionHistory = {
id: number;
Expand Down Expand Up @@ -151,7 +153,7 @@ export const columns : ColumnDef<QuestionHistory>[]= [
)
},
accessorKey: "attemptTime",
cell: ({ row }) => <div className="flex items-center justify-center h-full">{ Math.floor(row.getValue("attemptTime")/60)}</div>,
cell: ({ row }) => <div className="flex items-center justify-center h-full">{ Math.ceil(row.getValue("attemptTime")/60)}</div>,
// Cell: ({ value }) => Math.floor(value / 60), // Convert time spent in seconds to minutes
},
{
Expand All @@ -167,6 +169,43 @@ export const columns : ColumnDef<QuestionHistory>[]= [
)
},
accessorKey: "attemptDate",
cell: ({ row }) => row.getValue("attemptDate").toLocaleString(),
cell: ({ row }) => {
const attemptDate = row.getValue("attemptDate");
return new Date(attemptDate).toLocaleString("en-GB", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
},
},
{
id: "actions",
cell: ({ row }) => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Link href={`/profile/question-history/code?questionId=${row.getValue("id")}`} passHref>
View Code
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
enableResizing: false,
}
];
Loading

0 comments on commit c0e9fe0

Please sign in to comment.