diff --git a/README.md b/README.md index 53fd868..c3a8c6c 100644 --- a/README.md +++ b/README.md @@ -55,4 +55,10 @@ Check out our [Next.js deployment documentation](https://nextjs.org/docs/deploym ![Problems num page 2](docs/images/page-problems-num.png) +### Username page (`/users/[username]`) +![Username page 1](docs/images/page-users-username-loading.png) + +![Username page 2](docs/images/page-users-username.png) + + diff --git a/docs/images/page-users-username-loading.png b/docs/images/page-users-username-loading.png new file mode 100644 index 0000000..08c5ab3 Binary files /dev/null and b/docs/images/page-users-username-loading.png differ diff --git a/docs/images/page-users-username.png b/docs/images/page-users-username.png new file mode 100644 index 0000000..4e3a123 Binary files /dev/null and b/docs/images/page-users-username.png differ diff --git a/src/app/api/submissions/language/[problemNum]/route.tsx b/src/app/api/submissions/language/[problemNum]/route.tsx index 3824b98..4f17d19 100644 --- a/src/app/api/submissions/language/[problemNum]/route.tsx +++ b/src/app/api/submissions/language/[problemNum]/route.tsx @@ -6,7 +6,7 @@ import { uhuntProblemNumUrl, uhuntProblemSubmissionListUrl, } from "@/utils/constants"; -import { Language, Problem, Submission } from "@/types"; +import { Language, Problem, Submission, SubmissionLangType } from "@/types"; type getParamsType = { params: z.infer; @@ -64,13 +64,22 @@ export const GET = async (_request: Request, { params }: getParamsType) => { ); // increment count of key-value for their respective language ID - const responseData: getResponseType = submissionData.reduce((acc, cur) => { + const reducedData: Record = submissionData.reduce((acc, cur) => { const languageId = cur.lan; acc[languageId] = acc[languageId] + 1; return acc; }, languageObj); - delete responseData["undefined"]; + delete reducedData["undefined"]; + + const processedData: SubmissionLangType[] = Object.entries(reducedData).map( + ([key, value]) => { + return { + language: Language[key], + count: value, + }; + }, + ); - return Response.json(responseData); + return Response.json(processedData); }; diff --git a/src/app/api/submissions/overtime/[problemNum]/route.ts b/src/app/api/submissions/overtime/[problemNum]/route.ts index 0fd1cd1..de01da4 100644 --- a/src/app/api/submissions/overtime/[problemNum]/route.ts +++ b/src/app/api/submissions/overtime/[problemNum]/route.ts @@ -3,20 +3,13 @@ import { z } from "zod"; import { submissionOvertimeSchema as schema } from "@/schema"; import { NextResponse } from "next/server"; import { uhuntProblemNumUrl, uhuntSubmissionCountUrl } from "@/utils/constants"; -import { Problem } from "@/types"; +import { Problem, SubmissionsOvertimeLineChartType } from "@/types"; import moment, { Moment } from "moment"; type getParamsType = { params: z.infer; }; -export type getResponseType = { - name: string; - time: string; // time formatted to year - submissions: number; - fill: string; -} - /** * Get the submission count of a problem using `problem number` * The submission count will be a cumulative submission count @@ -75,7 +68,7 @@ export const GET = async (_request: Request, { params }: getParamsType) => { // 12 : Number of months each array element will represent const submissionUrlSplit = submssionCountUrl.split("/"); const thirtyDaysInSeconds = 60 * 60 * 24 * 30; - const responseData:getResponseType[] = data.map((cur, i) => { + const responseData:SubmissionsOvertimeLineChartType[] = data.map((cur, i) => { const submissionTime = +submissionUrlSplit[7]; const back = +submissionUrlSplit[8]; const jump = +submissionUrlSplit[9]; diff --git a/src/app/api/users/[username]/submissions/attempted/route.ts b/src/app/api/users/[username]/submissions/attempted/route.ts new file mode 100644 index 0000000..0af57d8 --- /dev/null +++ b/src/app/api/users/[username]/submissions/attempted/route.ts @@ -0,0 +1,94 @@ + +import { z } from "zod"; + +import { NextResponse } from "next/server"; + +import { userSchema as schema } from "@/schema"; +import { + Language, + ProblemVerdictMap, + SubmissionLangType, + SubmissionsOvertimeLineChartType, + SubmissionSovledVsAttempted +} from "@/types"; +import { RawUserSubmission } from "@/types/raw"; +import { + uhuntUserSubmissionsUrl, + uhuntUsername2UidUrl, +} from "@/utils/constants"; +import moment from "moment"; + +type getParamsType = { + params: z.infer; +}; + +export const GET = async (_request: Request, {params}: getParamsType) => { + // validate params + const schemaResponse = await schema.safeParseAsync(params); + if (!schemaResponse.success) { + const message = { + message: schemaResponse.error.issues[0].message, + }; + + return NextResponse.json(message, { + status: 400, + }); + } + + //----------------------------------------------------------------------------------------------// + + // fetch username ID + const { username } = params; + + const usernameUrl = uhuntUsername2UidUrl(username); + const usernameResponse = await fetch(usernameUrl); + const usernameData: number = await usernameResponse.json(); + + // return 404 if problem doesn't exist + if (usernameData === 0) { + const message = { + message: `Username ${username} not found`, + }; + return NextResponse.json(message, { + status: 404, + }); + } + + //----------------------------------------------------------------------------------------------// + // fetch submissions of the user + const userSubmissionsUrl = uhuntUserSubmissionsUrl(usernameData); + const userSubmissionResponse = await fetch(userSubmissionsUrl, { + cache: "no-cache", + }); + const userSubmissionData = + (await userSubmissionResponse.json()) as RawUserSubmission; + + // count solved submissions + // use a set to keep track of problems solved + const set = new Set(); + + const verdictSolvedId = 90 + userSubmissionData.subs.forEach(cur => { + // cur[2] : verdict ID + if(cur[2] === verdictSolvedId) { + set.add(cur[1]) // add problem ID + } + }) + + const processedData: SubmissionSovledVsAttempted[] = [ + { + name: "Solved", + count: set.size, + tooltipTitle: "Solved unique problems", + fill: ProblemVerdictMap.ac.bgHex + }, + { + name: "Attempts", + count: userSubmissionData.subs.length, + tooltipTitle: "Submission attempts", + fill: ProblemVerdictMap.inq.bgHex + } + ] + + return Response.json(processedData) +} diff --git a/src/app/api/users/[username]/submissions/language/route.ts b/src/app/api/users/[username]/submissions/language/route.ts new file mode 100644 index 0000000..10b07d1 --- /dev/null +++ b/src/app/api/users/[username]/submissions/language/route.ts @@ -0,0 +1,90 @@ +import { z } from "zod"; + +import { NextResponse } from "next/server"; + +import { userSchema as schema } from "@/schema"; +import { Language, SubmissionLangType } from "@/types"; +import { RawUserSubmission } from "@/types/raw"; +import { + uhuntUserSubmissionsUrl, + uhuntUsername2UidUrl, +} from "@/utils/constants"; + +type getParamsType = { + params: z.infer; +}; + +export const GET = async (_request: Request, { params }: getParamsType) => { + // validate params + const schemaResponse = await schema.safeParseAsync(params); + if (!schemaResponse.success) { + const message = { + message: schemaResponse.error.issues[0].message, + }; + + return NextResponse.json(message, { + status: 400, + }); + } + + //----------------------------------------------------------------------------------------------// + + // fetch username ID + const { username } = params; + + const usernameUrl = uhuntUsername2UidUrl(username); + const usernameResponse = await fetch(usernameUrl); + const usernameData: number = await usernameResponse.json(); + + // return 404 if problem doesn't exist + if (usernameData === 0) { + const message = { + message: `Username ${username} not found`, + }; + return NextResponse.json(message, { + status: 404, + }); + } + + //----------------------------------------------------------------------------------------------// + + // fetch submissions of the user + const userSubmissionsUrl = uhuntUserSubmissionsUrl(usernameData); + const userSubmissionResponse = await fetch(userSubmissionsUrl, { + cache: "no-cache", + }); + const userSubmissionData = + (await userSubmissionResponse.json()) as RawUserSubmission; + + // map language id as key and 0 as value. (the value will be count of a submission language) + let languageObj = Object.keys(Language).reduce( + (acc: Record, cur: string) => { + acc[cur] = 0; + + return acc; + }, + {}, + ); + + // count the submissions by language id + // object key: language ID + // object value: count + const reducedData = userSubmissionData.subs.reduce((acc, cur) => { + const languageId = cur[5]; + acc[languageId] = acc[languageId] + 1; + + return acc; + }, languageObj); + delete reducedData["undefined"]; + + const processedData: SubmissionLangType[] = Object.entries(reducedData).map( + ([key, value]) => { + return { + language: Language[key], + count: value, + }; + }, + ); + + return Response.json(processedData); +}; diff --git a/src/app/api/users/[username]/submissions/overtime/route.ts b/src/app/api/users/[username]/submissions/overtime/route.ts new file mode 100644 index 0000000..c7f1428 --- /dev/null +++ b/src/app/api/users/[username]/submissions/overtime/route.ts @@ -0,0 +1,117 @@ +import { z } from "zod"; + +import { NextResponse } from "next/server"; + +import { userSchema as schema } from "@/schema"; +import { + Language, + SubmissionLangType, + SubmissionsOvertimeLineChartType, +} from "@/types"; +import { RawUserSubmission } from "@/types/raw"; +import { + uhuntUserSubmissionsUrl, + uhuntUsername2UidUrl, +} from "@/utils/constants"; +import moment from "moment"; + +type getParamsType = { + params: z.infer; +}; + +export const GET = async (_request: Request, { params }: getParamsType) => { + // validate params + const schemaResponse = await schema.safeParseAsync(params); + if (!schemaResponse.success) { + const message = { + message: schemaResponse.error.issues[0].message, + }; + + return NextResponse.json(message, { + status: 400, + }); + } + + //----------------------------------------------------------------------------------------------// + + // fetch username ID + const { username } = params; + + const usernameUrl = uhuntUsername2UidUrl(username); + const usernameResponse = await fetch(usernameUrl); + const usernameData: number = await usernameResponse.json(); + + // return 404 if problem doesn't exist + if (usernameData === 0) { + const message = { + message: `Username ${username} not found`, + }; + return NextResponse.json(message, { + status: 404, + }); + } + + //----------------------------------------------------------------------------------------------// + // fetch submissions of the user + const userSubmissionsUrl = uhuntUserSubmissionsUrl(usernameData); + const userSubmissionResponse = await fetch(userSubmissionsUrl, { + cache: "no-cache", + }); + const userSubmissionData = + (await userSubmissionResponse.json()) as RawUserSubmission; + + // count submissions by year + const initData: Record = {}; + let reducedData = userSubmissionData.subs.reduce((obj, cur, index) => { + const year = moment.unix(cur[4]).format("YYYY"); + obj[year] = obj[year] + 1 || 1; + + return obj; + }, initData); + + // add missing years + // - loop through the array starting from index 1 going upto last array element + // - convert processedData into an array using Object.entries + // - sort the array + // - get the current element and previous element + // - loop through the difference between curYear and prevYear + // - add entry to `processedData` + for (let i = 1; i < Object.entries(reducedData).length; i++) { + const sorted = Object.entries(reducedData).sort((a, b) => +a[0] - +b[0]); + const curYear = +sorted[i][0]; + const prevYear = +sorted[i - 1][0]; + + // if (curYear - prevYear > 1) { + for (let k = prevYear + 1; k < curYear; k++) { + reducedData[k] = 0; + } + // } + } + + // calculate cumulative sum + for (let i = 1; i < Object.entries(reducedData).length; i++) { + const sorted = Object.entries(reducedData).sort((a, b) => +a[0] - +b[0]); + const [curYear, curYearCount] = sorted[i]; + const [prevYear, prevYearCount] = sorted[i - 1]; + + reducedData[curYear] = curYearCount + prevYearCount; + } + + // construct data structure for Rechart area/line chart + const initProcessedData: SubmissionsOvertimeLineChartType[] = []; + const processedData: SubmissionsOvertimeLineChartType[] = Object.entries( + reducedData, + ).reduce((obj, cur, index) => { + const [year, count] = cur; + obj.push({ + name: "submissions", + time: year, + submissions: count, + fill: "#8884d8", + }); + + return obj; + }, initProcessedData); + + return Response.json(processedData); +}; diff --git a/src/app/api/users/[username]/submissions/route.ts b/src/app/api/users/[username]/submissions/route.ts new file mode 100644 index 0000000..9c33693 --- /dev/null +++ b/src/app/api/users/[username]/submissions/route.ts @@ -0,0 +1,118 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { userSchema as schema } from "@/schema"; +import { + uhuntAllProblemsUrl, + uhuntUserSubmissionsUrl, + uhuntUsername2UidUrl, +} from "@/utils/constants"; +import { Language, ProblemVerdictMap, UserSub, UserSubmission } from "@/types"; +import { RawProblem, RawUserSubmission } from "@/types/raw"; + +type getParamsType = { + params: z.infer; +}; + +export const GET = async (_request: Request, { params }: getParamsType) => { + // validate params + const schemaResponse = await schema.safeParseAsync(params); + if (!schemaResponse.success) { + const message = { + message: schemaResponse.error.issues[0].message, + }; + + return NextResponse.json(message, { + status: 400, + }); + } + + //----------------------------------------------------------------------------------------------// + + // fetch username ID + const { username } = params; + + const usernameUrl = uhuntUsername2UidUrl(username); + const usernameResponse = await fetch(usernameUrl); + const usernameData: number = await usernameResponse.json(); + + // return 404 if problem doesn't exist + if (usernameData === 0) { + const message = { + message: `Username ${username} not found`, + }; + return NextResponse.json(message, { + status: 404, + }); + } + + //----------------------------------------------------------------------------------------------// + + // fetch all problems + // this is needed because later each submission will need problem number and problem title. + // if repeatedly fetching from upstream api server, it will slow down. + const allProblemsUrl = uhuntAllProblemsUrl(); + const allProblemsResponse = await fetch(allProblemsUrl); + const allProblemsData: RawProblem[] = await allProblemsResponse.json(); + + // fetch submissions of the user + const userSubmissionsUrl = uhuntUserSubmissionsUrl(usernameData); + const userSubmissionResponse = await fetch(userSubmissionsUrl, { + cache: "no-cache", + }); + const userSubmissionData = + (await userSubmissionResponse.json()) as RawUserSubmission; + + // change userSubmissionData.sub[] into UserSub[] + // originally the upstream api would return `userSubmissionData.sub` as an array of array + // // ex: + // [ + // [ + // 0: 5251911 // submission ID + // 1: 62 // problem ID + // 2: 10 // verdict ID + // 3: 0 // runtime + // 4: 1168359789 // submission time + // 5: 4 // language ID + // 6: -1 // rank + // ] + // ] + const converted = userSubmissionData.subs + .sort((a, b) => b[4] - a[4]) // sort by submission time in descending order + .slice(0, 500) // take only the most recent 500 submissions + .map((submission: number[]) => { + const converted: Partial = {}; + converted.sid = submission[0]; + converted.pid = submission[1]; + converted.ver = submission[2]; + converted.run = submission[3]; + converted.sbt = submission[4]; + converted.lan = submission[5] as unknown as string; + converted.rank = submission[6]; + + const problemData = allProblemsData.find( + (problem) => problem[0] === converted.pid, + ); + + converted.pnum = (problemData as RawProblem)[1]; + converted.pTitle = (problemData as RawProblem)[2]; + converted.verdict = ProblemVerdictMap[converted.ver as number] || { + fgColor: "text-primary-foreground dark:text-secondary-foreground", + bgColor: "bg-gray-500", + title: "- In Queue -", + fgHex: "", + bgHex: "6b7280", + }; + converted.lan = Language[converted.lan]; + + return converted; + }); + + const resultData: UserSubmission = { + name: userSubmissionData.name, + uname: userSubmissionData.uname, + subs: converted as UserSub[], + }; + + return Response.json(resultData); +}; diff --git a/src/app/api/users/[username]/submissions/verdict/route.ts b/src/app/api/users/[username]/submissions/verdict/route.ts new file mode 100644 index 0000000..f19e7c6 --- /dev/null +++ b/src/app/api/users/[username]/submissions/verdict/route.ts @@ -0,0 +1,89 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { userSchema as schema } from "@/schema"; +import { + uhuntUserSubmissionsUrl, + uhuntUsername2UidUrl, +} from "@/utils/constants"; +import { + ProblemVerdictMap, + UserSubmission, + UserSubmissionBarChartType, +} from "@/types"; + +type getParamsType = { + params: z.infer; +}; + +/** + * Get the user submissions by verdicts + * The data returned will be used in Rechart `bar` chart + */ +export const GET = async (_request: Request, { params }: getParamsType) => { + // validate params + const schemaResponse = await schema.safeParseAsync(params); + if (!schemaResponse.success) { + const message = { + message: schemaResponse.error.issues[0].message, + }; + + return NextResponse.json(message, { + status: 400, + }); + } + + //----------------------------------------------------------------------------------------------// + + // fetch username ID + const { username } = params; + + const usernameUrl = uhuntUsername2UidUrl(username); + const usernameResponse = await fetch(usernameUrl); + const usernameData: number = await usernameResponse.json(); + + // return 404 if problem doesn't exist + if (usernameData === 0) { + const message = { + message: `Username ${username} not found`, + }; + return NextResponse.json(message, { + status: 404, + }); + } + + //----------------------------------------------------------------------------------------------// + + // fetch submissions of the user + const userSubmissionsUrl = uhuntUserSubmissionsUrl(usernameData); + const userSubmissionResponse = await fetch(userSubmissionsUrl, { + cache: "no-cache", + }); + const userSubmissionData = + (await userSubmissionResponse.json()) as UserSubmission; + + const filter = ["ac", "pe", "wa", "tle", "mle", "ce", "re", "ole"]; + // count the verdicts by their verdict ID. + // use verdictShort as the object key + // this will be used when later when constructing the data for recharts + const reducedData = (userSubmissionData.subs as unknown as number[][]).reduce( + (obj: Record, cur, _index) => { + const verdict = ProblemVerdictMap[cur[2]]; + obj[verdict.verdictShort] = obj[verdict.verdictShort] + 1 || 1; + + return obj; + }, + {}, + ); + + const processedData: UserSubmissionBarChartType[] = filter.map((verdict) => { + return { + name: verdict.toUpperCase(), + verdict: reducedData[verdict] || 0, + tooltipTitle: ProblemVerdictMap[verdict].title, + fill: ProblemVerdictMap[verdict].bgHex, + }; + }); + + return Response.json(processedData); +}; diff --git a/src/app/problems/[problemNum]/page.tsx b/src/app/problems/[problemNum]/page.tsx index 9eeee8b..60728d5 100644 --- a/src/app/problems/[problemNum]/page.tsx +++ b/src/app/problems/[problemNum]/page.tsx @@ -22,7 +22,7 @@ import { columns } from "./components/data-table/ranklistColumns"; import Loading from "./loading"; import Link from "next/link"; import { uhuntViewProblemUrl } from "@/utils/constants"; -import { Problem, Submission } from "@/types"; +import { Problem, Submission, SubmissionLangType } from "@/types"; type problemPageProps = { params: z.infer; @@ -141,7 +141,7 @@ const ProblemPage = ({ params }: problemPageProps) => { Submissions by language - + diff --git a/src/app/users/[username]/components/data-table/submissionColumns.tsx b/src/app/users/[username]/components/data-table/submissionColumns.tsx new file mode 100644 index 0000000..33a52a2 --- /dev/null +++ b/src/app/users/[username]/components/data-table/submissionColumns.tsx @@ -0,0 +1,219 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import moment from "moment"; +import Link from "next/link"; + +import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { DataTableColumnHeader } from "@/components/ui/data-table/column-header"; +import { Submission, UserSub } from "@/types"; +import { cn } from "@/lib/utils"; + +export const columns: ColumnDef[] = [ + { + accessorKey: "submissionId", + accessorFn: (row) => row.sid, + meta: { + // for displaying the columns dropdown + headerTitle: "Submission ID", + }, + header: ({ column }) => { + return ; + }, + cell: ({ row }) => { + return ( + + {row.getValue("submissionId")} + + ); + }, + }, + { + accessorKey: "problemNum", + accessorFn: (row) => row.pnum, + meta: { + // for displaying the columns dropdown + headerTitle: "Problem Number", + }, + header: ({ column }) => { + return ; + }, + cell: ({ row }) => { + return ( + + {row.getValue("problemNum")} + + ); + }, + enableSorting: false, + }, + { + accessorKey: "problemTitle", + accessorFn: (row) => row.pTitle, + meta: { + // for displaying the columns dropdown + headerTitle: "Problem Title", + }, + header: ({ column }) => { + return ; + }, + cell: ({ row }) => { + return ( + + {row.getValue("problemTitle")} + + ); + }, + enableSorting: false, + }, + // { + // accessorKey: "username", + // accessorFn: (row) => `${row.name} (${row.uname})`, + // meta: { + // // for displaying the columns dropdown + // headerTitle: "User (username)", + // }, + // header: ({ column }) => { + // return ; + // }, + // cell: ({ row }) => { + // if (row.original.uname === "--- ? ---") { + // return ( + //

{row.original.uname}

+ // ) + // } + // return ( + // + // {row.getValue("username")} + // + // ); + // }, + // }, + { + accessorKey: "verdict", + accessorFn: (row) => row.verdict.title, + meta: { + // for displaying the columns dropdown + headerTitle: "Verdict", + }, + header: ({ column }) => { + return ; + }, + cell: ({ row }) => { + return ( + + {row.getValue("verdict")} + + ); + }, + enableSorting: false, + }, + { + accessorKey: "language", + accessorFn: (row) => row.lan, + meta: { + // for displaying the columns dropdown + headerTitle: "Language", + }, + header: ({ column }) => { + return ; + }, + cell: ({ row }) => { + return

{row.getValue("language")}

; + }, + enableSorting: false, + }, + { + accessorKey: "runtime", + accessorFn: (row) => row.run, + meta: { + // for displaying the columns dropdown + headerTitle: "Runtime", + }, + header: ({ column }) => { + return ; + }, + cell: ({ row }) => { + return

{((row.getValue("runtime") as number) / 1000).toFixed(3)}

; + }, + enableSorting: false, + }, + { + accessorKey: "rank", + accessorFn: (row) => row.rank, + meta: { + // for displaying the columns dropdown + headerTitle: "Rank", + }, + header: ({ column }) => { + return ; + }, + cell: ({ row }) => { + return

{row.getValue("rank")}

; + }, + enableSorting: false, + }, + { + accessorKey: "submitTime", + accessorFn: (row) => row.sbt, + meta: { + // for displaying the columns dropdown + headerTitle: "Submit Time", + }, + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return ( + + + +

+ {moment.unix(row.getValue("submitTime")).fromNow()} +

+
+ +

+ Submitted at{" "} + {moment.unix(row.getValue("submitTime")).toLocaleString()} +

+
+
+
+ ); + }, + enableSorting: false, + }, +]; + diff --git a/src/app/users/[username]/loading.tsx b/src/app/users/[username]/loading.tsx new file mode 100644 index 0000000..53147ae --- /dev/null +++ b/src/app/users/[username]/loading.tsx @@ -0,0 +1,27 @@ +import Loading from "@/components/ui/data-table/loading"; +import { Skeleton } from "@/components/ui/skeleton"; +import React from "react"; + +const UsernameLoading = () => { + return ( +
+ +
+ {[...Array(4)].map((cur, index) => ( +
+ {" "} + {" "} +
+ ))} +
+
+
+ + +
+
+
+ ); +}; + +export default UsernameLoading; diff --git a/src/app/users/[username]/page.tsx b/src/app/users/[username]/page.tsx new file mode 100644 index 0000000..f4e04ff --- /dev/null +++ b/src/app/users/[username]/page.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { AxiosError } from "axios"; +import { z } from "zod"; + +import Error from "@/components/error"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { userSchema } from "@/schema"; +import SubmissionVerdictChart from "@/components/charts/ProblemVerdictChart"; +import { + useFetchUserSubmissionAttempted, + useFetchUserSubmissionLanguage, + useFetchUserSubmissionOvertime, + useFetchUserSubmissions, + useFetchUserSubmissionVerdict, +} from "@/hooks"; +import { + SubmissionLangType, + SubmissionSovledVsAttempted, + SubmissionsOvertimeLineChartType, + UserSubmission, + UserSubmissionBarChartType, +} from "@/types"; +import { DataTable } from "@/components/ui/data-table"; +import { columns } from "@/app/users/[username]/components/data-table/submissionColumns"; +import SubmissionLanguageRadarChart from "@/components/charts/SubmissionLanguageRadarChart"; +import SubmissionsOvertimeChart from "@/components/charts/SubmissionsOvertimeChart"; +import SolvedVsAttemptedDonutChart from "@/components/charts/SolvedVsAttemptedDonutChart"; +import Loading from "./loading"; + +type Props = { + params: z.infer; +}; + +const UserPage = ({ params }: Props) => { + const { + isFetching: userSubmissionIsFetching, + isError: userSubmissionIsError, + data: userSubmissionData, + error: userSubmissionError, + } = useFetchUserSubmissions(params.username); + const { + isFetching: userSubmissionVerdictIsFetching, + data: userSubmissionVerdictData, + } = useFetchUserSubmissionVerdict(params.username); + const { + isFetching: userSubmissionLanguageIsFetching, + data: userSubmissionLanguageData, + } = useFetchUserSubmissionLanguage(params.username); + const { + isFetching: userSubmissionOvertimeIsFetching, + data: userSubmissionOvertimeData, + } = useFetchUserSubmissionOvertime(params.username); + const { + isFetching: userSubmissionAttemptedIsFetching, + data: userSubmissionAttemptedData, + } = useFetchUserSubmissionAttempted(params.username); + + if ( + userSubmissionIsFetching || + userSubmissionVerdictIsFetching || + userSubmissionLanguageIsFetching || + userSubmissionOvertimeIsFetching || + userSubmissionAttemptedIsFetching + ) { + return ; + } + + if (userSubmissionIsError) { + type ErrorMessage = { + message: string; + }; + + const status = (userSubmissionError as AxiosError).response?.status; + const message = (userSubmissionError as AxiosError).response?.data.message; + + return ( + + ); + } + + return ( +
+
+

+ {userSubmissionData?.name} ({params.username}) +

+
+
+
+ + + Submission Verdicts + + + + + +
+ {/* Submissions overtime line chart */} +
+ + + Submissions overtime + + + + + +
+ {/* Submissions language using radar chart */} +
+ + + Submissions by language + + + + + +
+ {/* Problem solved vs attempted with donut chart */} +
+ + + Solved problems vs Problem submissions + + + + + +
+
+
+
+
+

Submissions

+ +
+
+
+
+ ); +}; + +export default UserPage; diff --git a/src/components/charts/ProblemVerdictChart.tsx b/src/components/charts/ProblemVerdictChart.tsx index f65ef7e..473ba04 100644 --- a/src/components/charts/ProblemVerdictChart.tsx +++ b/src/components/charts/ProblemVerdictChart.tsx @@ -13,11 +13,11 @@ import { XAxis, } from "recharts"; -import { processedProblemVerdictBarChartType } from "@/utils/dataProcessing"; +import { VerdictBarChartType } from "@/types"; import ChartTooltip from "@/components/charts/Tooltip"; type Props = { - data: processedProblemVerdictBarChartType[]; + data: VerdictBarChartType[]; }; const ProblemVerdictChart = ({ data }: Props) => { diff --git a/src/components/charts/SolvedVsAttemptedDonutChart.tsx b/src/components/charts/SolvedVsAttemptedDonutChart.tsx new file mode 100644 index 0000000..f383d55 --- /dev/null +++ b/src/components/charts/SolvedVsAttemptedDonutChart.tsx @@ -0,0 +1,49 @@ +import { Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"; + +import { SubmissionSovledVsAttempted } from "@/types"; +import { cn } from "@/lib/utils"; +import ChartTooltip from "@/components/charts/Tooltip"; + +type Props = { + data: SubmissionSovledVsAttempted[]; +}; + +const SolvedVsAttemptedDonutChart = ({ data }: Props) => { + return ( + + + + ( + `${new Intl.NumberFormat("us").format(number).toString()}`} + labelFormatter={(payload) => payload[0].payload.tooltipTitle} + /> + )} + /> + + + ); +}; + +export default SolvedVsAttemptedDonutChart; diff --git a/src/components/charts/SubmissionLanguageRadarChart.tsx b/src/components/charts/SubmissionLanguageRadarChart.tsx index 21c3a29..8751331 100644 --- a/src/components/charts/SubmissionLanguageRadarChart.tsx +++ b/src/components/charts/SubmissionLanguageRadarChart.tsx @@ -9,20 +9,18 @@ import { import { useTheme } from "next-themes"; import ChartTooltip from "@/components/charts/Tooltip"; -import { processSubmissionLanguageRadarChart } from "@/utils/dataProcessing"; -import { getResponseType } from "@/app/api/submissions/language/[problemNum]/route"; +import { SubmissionLangType } from "@/types"; type Props = { - data: getResponseType; + data: SubmissionLangType[]; }; const SubmissionLanguageRadarChart = ({ data }: Props) => { const { theme } = useTheme(); - const processedData = processSubmissionLanguageRadarChart(data); return ( - + { diff --git a/src/components/charts/Tooltip.tsx b/src/components/charts/Tooltip.tsx index e360e7d..66f5d29 100644 --- a/src/components/charts/Tooltip.tsx +++ b/src/components/charts/Tooltip.tsx @@ -49,6 +49,7 @@ export const ChartTooltipRow = ({ value, name, color }: ChartTooltipRowProps) => "w-3", "rounded-md", )} + style={{backgroundColor: color}} />

{ export const useFetchProblems = () => { return useQuery({ queryKey: [queryKey.allProblems], - queryFn: async () => axios.get("/api/problems").then((res) => res.data), + queryFn: async () => + axios.get("/api/problems").then((res) => res.data), refetchOnWindowFocus: false, }); }; @@ -71,7 +101,9 @@ export const useFetchProblemNum = (problemNum: number) => { return useQuery({ queryKey: [queryKey.problemNum], queryFn: async () => - await axios.get(`/api/problems/${problemNum}`).then((res) => res.data), + await axios + .get(`/api/problems/${problemNum}`) + .then((res) => res.data), refetchOnWindowFocus: false, }); }; @@ -98,7 +130,7 @@ export const useFetchSubmissionLang = (problemNum: number) => { queryKey: [queryKey.submissionLang], queryFn: async () => await axios - .get(`/api/submissions/language/${problemNum}`) + .get(`/api/submissions/language/${problemNum}`) .then((res) => res.data), refetchOnWindowFocus: false, }); @@ -112,7 +144,7 @@ export const useFetchProblemRanklist = (problemNum: number) => { queryKey: [queryKey.problemRanklist], queryFn: async () => await axios - .get(`/api/problems/ranklist/${problemNum}`) + .get(`/api/problems/ranklist/${problemNum}`) .then((res) => res.data), refetchOnWindowFocus: false, }); @@ -126,8 +158,93 @@ export const useFetchProblemSubmission = (problemNum: number) => { queryKey: [queryKey.problemSubmission], queryFn: async () => await axios - .get(`/api/submissions/${problemNum}`) + .get(`/api/submissions/${problemNum}`) .then((res) => res.data), refetchOnWindowFocus: false, }); }; + +/////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * Fetch user submissions + */ +export const useFetchUserSubmissions = (username: string) => { + return useQuery({ + queryKey: [queryKey.userSubmissions], + queryFn: async () => + await axios + .get(`/api/users/${username}/submissions`) + .then((res) => res.data), + refetchOnWindowFocus: false, + }); +}; + +/** + * Fetch user submissions verdicts + * Used for displaying data using Rechart bar chart + */ +export const useFetchUserSubmissionVerdict = (username: string) => { + return useQuery({ + queryKey: [queryKey.userSubmissionVerdict], + queryFn: async () => + await axios + .get( + `/api/users/${username}/submissions/verdict`, + ) + .then((res) => res.data), + refetchOnWindowFocus: false, + }); +}; + +/** + * Fetch user submissions by language + * Used for displaying data using Rechart radar chart + */ +export const useFetchUserSubmissionLanguage = (username: string) => { + return useQuery({ + queryKey: [queryKey.userSubmissionLanguage], + queryFn: async () => + await axios + .get( + `/api/users/${username}/submissions/language`, + ) + .then((res) => res.data), + refetchOnWindowFocus: false, + }); +}; + +/** + * Fetch user submissions by language + * Used for displaying data using Rechart radar chart + */ +export const useFetchUserSubmissionOvertime = (username: string) => { + return useQuery({ + queryKey: [queryKey.userSubmissionOvertime], + queryFn: async () => + await axios + .get( + `/api/users/${username}/submissions/overtime`, + ) + .then((res) => res.data), + refetchOnWindowFocus: false, + }); +}; + +/** + * Fetch user solved problems vs attempted submissions + * Used for displaying data using Rechart donut chart + */ +export const useFetchUserSubmissionAttempted = (username: string) => { + return useQuery({ + queryKey: [queryKey.userSubmissionAttempted], + queryFn: async () => + await axios + .get( + `/api/users/${username}/submissions/attempted`, + ) + .then((res) => res.data), + refetchOnWindowFocus: false, + }); +}; + diff --git a/src/schema/index.ts b/src/schema/index.ts index c5cbe3f..6762ce4 100644 --- a/src/schema/index.ts +++ b/src/schema/index.ts @@ -32,3 +32,12 @@ export const problemNumSubmissionSchema = z.object({ .number({ invalid_type_error: "Problem number must be a number" }) .min(1, "Problem number must be a number greater than 0"), }) + +/////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * Schema validation for endpoint `/api/users/[username]/submissions` + */ +export const userSchema = z.object({ + username: z.coerce.string().min(1, "Username must have at least on character") +}) diff --git a/src/types/index.ts b/src/types/index.ts index 14aecb6..46a4685 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -43,6 +43,10 @@ export interface ProblemVerdictType { * Usually used for displaying verdict using shadcn-ui `Badge` component */ fgHex: string; + /** + * Verdict shorthand string + */ + verdictShort: string; } const ProblemVerdictMap: Record = { @@ -55,6 +59,7 @@ const ProblemVerdictMap: Record = { fgColor: "text-primary-foreground dark:text-secondary-foreground", bgHex: "#00AA00", fgHex: "", + verdictShort: "ac" }, /** * Number of Presentation Error @@ -65,6 +70,7 @@ const ProblemVerdictMap: Record = { fgColor: "text-primary-foreground dark:text-secondary-foreground", bgHex: "#666600", fgHex: "", + verdictShort: "pe" }, /** * Number of Wrong Answer @@ -75,6 +81,7 @@ const ProblemVerdictMap: Record = { fgColor: "text-primary-foreground dark:text-secondary-foreground", bgHex: "#FF0000", fgHex: "", + verdictShort: "wa" }, /** * Number of Time Limit Exceeded @@ -85,6 +92,7 @@ const ProblemVerdictMap: Record = { fgColor: "text-primary-foreground dark:text-secondary-foreground", bgHex: "#0000FF", fgHex: "", + verdictShort: "tle" }, /** * Number of Memory Limit Exceeded @@ -95,6 +103,7 @@ const ProblemVerdictMap: Record = { fgColor: "text-primary-foreground dark:text-secondary-foreground", bgHex: "#0000AA", fgHex: "", + verdictShort: "mle" }, /** * Number of Compilation Error @@ -105,6 +114,7 @@ const ProblemVerdictMap: Record = { fgColor: "text-primary-foreground dark:text-secondary-foreground", bgHex: "#EA5A0C", fgHex: "", + verdictShort: "ce" }, /** * Number of Runtime Error @@ -115,6 +125,7 @@ const ProblemVerdictMap: Record = { fgColor: "text-primary-foreground dark:text-secondary-foreground", bgHex: "#00AAAA", fgHex: "", + verdictShort: "re" }, /** * Number of Output Limit Exceeded @@ -125,6 +136,7 @@ const ProblemVerdictMap: Record = { fgColor: "text-primary-foreground dark:text-secondary-foreground", bgHex: "#000066", fgHex: "", + verdictShort: "ole" }, /** * Number of Submission Error @@ -133,8 +145,9 @@ const ProblemVerdictMap: Record = { title: "Submission Error", bgColor: "bg-gray-500", fgColor: "text-primary-foreground dark:text-secondary-foreground", - bgHex: "6b7280", + bgHex: "#6b7280", fgHex: "", + verdictShort: "sube" }, /** * Number of Can't be Judged @@ -143,8 +156,9 @@ const ProblemVerdictMap: Record = { title: "Can't be judged", bgColor: "bg-gray-500", fgColor: "text-primary-foreground dark:text-secondary-foreground", - bgHex: "6b7280", + bgHex: "#6b7280", fgHex: "", + verdictShort: "noj" }, /** * Number of In Queue @@ -153,8 +167,9 @@ const ProblemVerdictMap: Record = { title: "- In Queue -", bgColor: "bg-gray-500", fgColor: "text-primary-foreground dark:text-secondary-foreground", - bgHex: "6b7280", + bgHex: "#6b7280", fgHex: "", + verdictShort: "inq" }, /** * Number of Restricted Function @@ -163,8 +178,9 @@ const ProblemVerdictMap: Record = { title: "Restricted function", bgColor: "bg-gray-500", fgColor: "text-primary-foreground dark:text-secondary-foreground", - bgHex: "6b7280", + bgHex: "#6b7280", fgHex: "", + verdictShort: "rf" } } // adding keys from `Submission.ver` into object `ProblemVerdictMap` @@ -372,3 +388,163 @@ export type Submission = { }; /////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * Submissions array returned when fetching user submissions + */ +export type UserSub = { + sid: number; + pid: number; + pnum: number; + pTitle: string; + ver: number; + verdict: ProblemVerdictType, + run: number; + sbt: number + lan: string; + rank: number +} + +// user submissions + +export type UserSubmission = { + /** + * name of the user + */ + name: string; + /** + * username of the user + */ + uname: string; + /** + * User submissions + */ + subs: UserSub[] +} + +/** + * Data structure for display user submissions using Rechart bar chart + * Used in api endpoint `/api/users/[username]/submissions/verdict` + */ +export type UserSubmissionBarChartType = { + /** + * Name of the bar in the bar chart. + * usually the verdict acronyms + */ + name: string; + /** + * The value of the verdict + */ + verdict: number; + /** + * Tooltip title to display. + * Usually would be the full string of a verdict + */ + tooltipTitle: string; + /** + * Color for bar + */ + fill: string; +}; + +////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * Data type used by Rechart radar chart. + * + * This is used to display submissions by language + */ +export type SubmissionLangType = { + /** + * Programmign language used for the submission + */ + language: string; + /** + * The sum of submissions using this language + */ + count: number; +}; + +////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * Data type used by Rechart bar chart. + * Specifically used for `Problem verdict bar chart` and `Submission verdict bar chart` + * + * This is used to display submissions by verdict + */ +export type VerdictBarChartType = { + /** + * Name of the bar in the bar chart. + * usually the verdict acronyms + */ + name: string; + /** + * The value of the verdict + */ + verdict: number; + /** + * Tooltip title to display. + * Usually would be the full string of a verdict + */ + tooltipTitle: string; + /** + * Color for bar + */ + fill: string; +}; + +////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * Data type used by Rechart area chart. + * + * Specifically used for `Problem submissions overtime` and `User submissions overtime` + * + * This is used to display submissions overtime + */ +export type SubmissionsOvertimeLineChartType = { + /** + * name of the entry + */ + name: string; + /** + * Submission year. Denoted as YYYY + */ + time: string; // time formatted to year + /** + * Submission count for the year + */ + submissions: number; + /** + * Color used for the chart + */ + fill: string; +} + +////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * Data type used by Rechart dounut chart. + * + * This is used to display submissions solved vs attempted + */ +export type SubmissionSovledVsAttempted = { + /** + * Name of segment + */ + name: string; + /** + * Value of segment + */ + count: number; + /** + * Tooltip title to display. + * Usually would be the full string of a verdict + */ + tooltipTitle: string; + /** + * Color used for the chart + */ + fill: string; +} diff --git a/src/types/raw.ts b/src/types/raw.ts new file mode 100644 index 0000000..6d2611f --- /dev/null +++ b/src/types/raw.ts @@ -0,0 +1,185 @@ +/** + * Raw Problem data return from uhunt apo + * + * https://uhunt.onlinejudge.org/api/p/id/[problemNum] + * + * Ex: https://uhunt.onlinejudge.org/api/p/num/100 + * + * Also used in uhunt api. NOTE: it returns an array of `RawProblem` + * https://uhunt.onlinejudge.org/api/p + * + * contains the data in order + * - problem ID + * - problem number + * - problem title + * - Number of Distinct Accepted User (DACU) + * - Best Runtime of an Accepted Submission + * - Best Memory used of an Accepted Submission + * - Number of No Verdict Given (can be ignored) + * - Number of Submission Error + * - Number of Can't be Judged + * - Number of In Queue + * - Number of Compilation Error + * - Number of Restricted Function + * - Number of Runtime Error + * - Number of Output Limit Exceeded + * - Number of Time Limit Exceeded + * - Number of Memory Limit Exceeded + * - Number of Wrong Answer + * - Number of Presentation Error + * - Number of Accepted + * - Problem Run-Time Limit (milliseconds) + * - Problem Status (0 = unavailable, 1 = normal, 2 = special judge) + */ +export type RawProblem = [ + /** + * Problem ID + */ + number, + /** + * Problem number + */ + number, + /** + * Problem title + */ + string, + /** + * Number of Distinct Accepted User (DACU) + */ + number, + /** + * Best Runtime of an Accepted Submission + */ + number, + /** + * Best Memory used of an Accepted Submission + */ + number, + /** + * Number of No Verdict Given (can be ignored) + */ + number, + /** + * Number of Submission Error + */ + number, + /** + * Number of Can't be Judged + */ + number, + /** + * Number of In Queue + */ + number, + /** + * Number of Compilation Error + */ + number, + /** + * Number of Restricted Function + */ + number, + /** + * Number of Runtime Error + */ + number, + /** + * Number of Output Limit Exceeded + */ + number, + /** + * Number of Time Limit Exceeded + */ + number, + /** + * Number of Memory Limit Exceeded + */ + number, + /** + * Number of Wrong Answer + */ + number, + /** + * Number of Presentation Error + */ + number, + /** + * Number of Accepted + */ + number, + /** + * Problem Run-Time Limit (milliseconds) + */ + number, + /** + * Problem Status (0 = unavailable, 1 = normal, 2 = special judge) + */ + number, + number, +]; + +////////////////////////////////////////////////////////////////////////////////////////////////// +/** + * Raw User sub + * + * contains the data in order + * - Submission ID + * - Problem ID + * - Verdict ID + * - Runtime + * - Submission time (unix timestamp) + * - Language ID + * - Submission Rank + */ +export type RawUserSubs = [ + /** + * Submission ID + */ + number, + /** + * Problem ID + */ + number, + /** + * Verdict ID + */ + number, + /** + * Runtime + */ + number, + /** + * Submission time (unix timestamp) + */ + number, + /** + * Language ID + * - 1=ANSI C + * - 2=Java + * - 3=C++ + * - 4=Pascal + * - 5=C++11 + * - 6=Python + */ + number, + /** + * Submission rank + */ + number, +]; + +/** + * Raw data returned when fetching from uhunt api + * + * https://uhunt.onlinejudge.org/api/subs-user/[userID] + * + * Ex: https://uhunt.onlinejudge.org/api/subs-user/339 + */ +export type RawUserSubmission = { + name: string; + uname: string; + subs: RawUserSubs[]; +}; + +////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/src/utils/dataProcessing.ts b/src/utils/dataProcessing.ts index 9d34205..4013e38 100644 --- a/src/utils/dataProcessing.ts +++ b/src/utils/dataProcessing.ts @@ -1,26 +1,8 @@ import { Language, Problem, ProblemVerdictMap, ProblemVerdictType } from "@/types"; import {getResponseType as submissionLangType} from '@/app/api/submissions/language/[problemNum]/route' +import { SubmissionLangType } from "@/types"; +import { VerdictBarChartType } from "@/types"; -export type processedProblemVerdictBarChartType = { - /** - * Name of the bar in the bar chart. - * usually the verdict acronyms - */ - name: string; - /** - * The value of the verdict - */ - verdict: number; - /** - * Tooltip title to display. - * Usually would be the full string of a verdict - */ - tooltipTitle: string; - /** - * Color for bar - */ - fill: string; -}; export const processProblemNumBarChartData = (data: Problem) => { // filter out the ProblemVerdictMap object and keep keys from `filter` array const filter = ["ac", "pe", "wa", "tle", "mle", "ce", "re", "ole"] @@ -32,7 +14,7 @@ export const processProblemNumBarChartData = (data: Problem) => { // obtained from https://stackoverflow.com/a/69676994/3053548 const filteredVerdicts: Record = Object.fromEntries(filter.map(k => [k, ProblemVerdictMap[k]])) - const processedData:processedProblemVerdictBarChartType[] = [] + const processedData:VerdictBarChartType[] = [] for(const [key, value] of Object.entries(filteredVerdicts)) { processedData.push({ @@ -48,15 +30,10 @@ export const processProblemNumBarChartData = (data: Problem) => { ////////////////////////////////////////////////////////////////////////////////////////////////// -export type processedSubmissionLangType = { - language: string; - count: number; -} - export const processSubmissionLanguageRadarChart = ( data: submissionLangType, -): processedSubmissionLangType[] => { - const processedData: processedSubmissionLangType[] = []; +): SubmissionLangType[] => { + const processedData: SubmissionLangType[] = []; Object.entries(data).forEach(([key, value]) => { processedData.push({