From 0b6a66b12d3c7a0df5a5a5636e42c657cf8e9f6f Mon Sep 17 00:00:00 2001 From: Joao Ramos Date: Thu, 28 Dec 2023 23:41:21 +0000 Subject: [PATCH 01/29] Initial version of PDF export --- package.json | 3 + src/exams/pdf/index.tsx | 141 +++++++ src/exams/pdf/progress.bar.tsx | 51 +++ src/pages/api/stats/[id]/export.tsx | 100 +++++ .../api/stats/{[user].ts => [id]/index.ts} | 2 +- yarn.lock | 379 +++++++++++++++++- 6 files changed, 670 insertions(+), 6 deletions(-) create mode 100644 src/exams/pdf/index.tsx create mode 100644 src/exams/pdf/progress.bar.tsx create mode 100644 src/pages/api/stats/[id]/export.tsx rename src/pages/api/stats/{[user].ts => [id]/index.ts} (96%) diff --git a/package.json b/package.json index 1a4b6ec2..0587b4e9 100644 --- a/package.json +++ b/package.json @@ -17,12 +17,14 @@ "@next/font": "13.1.6", "@paypal/paypal-js": "^7.1.0", "@paypal/react-paypal-js": "^8.1.3", + "@react-pdf/renderer": "^3.1.14", "@tanstack/react-table": "^8.10.1", "@types/node": "18.13.0", "@types/react": "18.0.27", "@types/react-dom": "18.0.10", "axios": "^1.3.5", "bcrypt": "^5.1.1", + "blob-stream": "^0.1.3", "chart.js": "^4.2.1", "clsx": "^1.2.1", "countries-list": "^3.0.1", @@ -74,6 +76,7 @@ "zustand": "^4.3.6" }, "devDependencies": { + "@types/blob-stream": "^0.1.33", "@types/formidable": "^3.4.0", "@types/howler": "^2.2.11", "@types/lodash": "^4.14.191", diff --git a/src/exams/pdf/index.tsx b/src/exams/pdf/index.tsx new file mode 100644 index 00000000..02adb77c --- /dev/null +++ b/src/exams/pdf/index.tsx @@ -0,0 +1,141 @@ +import React from "react"; +import { Document, Page, View, Text, StyleSheet } from "@react-pdf/renderer"; +import ProgressBar from "./progress.bar"; + +const styles = StyleSheet.create({ + body: { + paddingTop: 35, + paddingBottom: 65, + paddingHorizontal: 35, + }, + titleView: { + display: "flex", + // flex: 1, + alignItems: "center", + }, + title: { + textTransform: "uppercase", + }, + textPadding: { + margin: "16px", + }, + userSection: { + fontWeight: "bold", + }, + separator: { + width: "100%", + borderBottom: "1px solid blue", + }, + textColor: { + color: "blue", + }, + textUnderline: { + textDecoration: "underline", + }, + skillsTitle: { + fontSize: 14, + }, + skillsText: { + fontSize: 12, + }, + footerText: { + fontSize: 9, + }, +}); +interface Props { + date: string; + name: string; + email: string; + id: string; + gender?: string; +} + +const PDFReport = ({ date, name, email, id, gender }: Props) => { + return ( + + + + + English Skills Test Result Report + + + + Date of Test: {date} + + Candidate Information: + + Name: {name} + ID: {id} + Email: {email} + Gender: {gender} + + + Test Details: + + + Performance Summary + + + + + + + + Skills Feedback + + + + Listening + + xxx + Reading + xxx + Writing + xxx + Speaking + xxx + + + + + Validity + + This report remains valid for a duration of three months from the + test date. Confidential – circulated for concern people + + Declaration + + We hereby declare that exam results on our platform, assessed by AI, + are not the sole determinants of candidates' English + proficiency levels. While EnCoach provides feedback based on + assessments, we recognize that language proficiency encompasses + practical application, cultural understanding, and real-life + communication. We urge users to consider exam results as a measure + of progress and improvement, and we continuously enhance our system + to ensure accuracy and reliability. + + + + + + info@encoach.com + https://encoach.com + Group ID: TRI64BNBOIU5043 + + + + + ); +}; + +export default PDFReport; diff --git a/src/exams/pdf/progress.bar.tsx b/src/exams/pdf/progress.bar.tsx new file mode 100644 index 00000000..ef701132 --- /dev/null +++ b/src/exams/pdf/progress.bar.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import { View, StyleSheet } from "@react-pdf/renderer"; + +const styles = StyleSheet.create({ + progressBar: { + borderRadius: 16, + overflow: "hidden", + }, + progressBarPerc: { + height: "100%", + zIndex: 1, + }, +}); + +interface Props { + width: number; + height: number; + backgroundColor: string; + progressColor: string; + percentage: number; +} + +const ProgressBar = ({ + width, + height, + backgroundColor, + progressColor, + percentage, +}: Props) => { + return ( + + + + ); +}; + +export default ProgressBar; \ No newline at end of file diff --git a/src/pages/api/stats/[id]/export.tsx b/src/pages/api/stats/[id]/export.tsx new file mode 100644 index 00000000..744a993c --- /dev/null +++ b/src/pages/api/stats/[id]/export.tsx @@ -0,0 +1,100 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { app, storage } from "@/firebase"; +import { + getFirestore, + doc, + getDoc, + deleteDoc, + updateDoc, +} from "firebase/firestore"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import ReactPDF from "@react-pdf/renderer"; +import PDFReport from "@/exams/pdf"; +import { + ref, + uploadBytes, + deleteObject, + getDownloadURL, +} from "firebase/storage"; +import blobStream from "blob-stream"; +import { Stat } from "@/interfaces/user"; +import { User } from "@/interfaces/user"; + +const db = getFirestore(app); + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "POST") return post(req, res); +} + +export const streamToBuffer = async ( + stream: NodeJS.ReadableStream, + ): Promise => { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on('data', (data) => { + chunks.push(data); + }); + stream.on('end', () => { + resolve(Buffer.concat(chunks)); + }); + stream.on('error', reject); + }); + }; + +async function post(req: NextApiRequest, res: NextApiResponse) { + // debugger; + if (req.session.user) { + const { id } = req.query as { id: string }; + + const docRef = doc(db, "stats", id); + const docSnap = await getDoc(docRef); + + if (docSnap.exists()) { + const stat = docSnap.data() as Stat; + + if (stat.user !== req.session.user.id) { + res.status(401).json(undefined); + return; + } + + // if (stat.pdf) { + // res.status(200).end(docSnap.pdf); + // return; + // } + const docUser = await getDoc(doc(db, "users", stat.user)); + + if (docUser.exists()) { + const user = docUser.data() as User; + const fileName = `${Date.now().toString()}.pdf`; + const fileRef = ref(storage, `exam_report/${fileName}`); + const pdfStream = await ReactPDF.renderToStream( + + ); + + const pdfBuffer = await streamToBuffer(pdfStream); + const snapshot = await uploadBytes(fileRef, pdfBuffer, { + contentType: 'application/pdf', + }); + await updateDoc(docRef, { + pdf: snapshot.ref.fullPath, + }); + res.status(200).end(snapshot.ref.fullPath); + return; + } + } + + res.status(500).json({ ok: false }); + return; + } + + res.status(401).json(undefined); +} diff --git a/src/pages/api/stats/[user].ts b/src/pages/api/stats/[id]/index.ts similarity index 96% rename from src/pages/api/stats/[user].ts rename to src/pages/api/stats/[id]/index.ts index 57c5f2e7..9cda3608 100644 --- a/src/pages/api/stats/[user].ts +++ b/src/pages/api/stats/[id]/index.ts @@ -15,7 +15,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { return; } - const {user} = req.query; + const {id: user} = req.query; const q = query(collection(db, "stats"), where("user", "==", user)); const snapshot = await getDocs(q); diff --git a/yarn.lock b/yarn.lock index 24bb6199..507baf96 100644 --- a/yarn.lock +++ b/yarn.lock @@ -48,6 +48,13 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.20.13": + version "7.23.6" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.6.tgz#c05e610dc228855dc92ef1b53d07389ed8ab521d" + integrity sha512-zHd0eUrf5GZoOWVCXp6koAKQTfZV07eit6bGPmJgnZdnSAvvZee6zniW2XMF7Cmc4ISOOnPy3QaSiIJGJkVEDQ== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/runtime@^7.20.7", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7": version "7.21.0" resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.0.tgz" @@ -1019,6 +1026,144 @@ resolved "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz" integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== +"@react-pdf/fns@2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@react-pdf/fns/-/fns-2.0.1.tgz#8948464044fc8a69975d9d07b1a12673377b72e2" + integrity sha512-/vgecczzFYBQFkgUupH+sxXhLWQtBwdwCgweyh25XOlR4NZuaMD/UVUDl4loFHhRQqDMQq37lkTcchh7zzW6ug== + dependencies: + "@babel/runtime" "^7.20.13" + +"@react-pdf/font@^2.3.7": + version "2.3.7" + resolved "https://registry.yarnpkg.com/@react-pdf/font/-/font-2.3.7.tgz#f74de022724d2f1529c73250c71c74c932e5c484" + integrity sha512-NoCieWea6c1mCpDBoyjPbUEC1qXa+S/M7+8vYPZ71aTMgX7co3gQc2e6YKwrSQeQP+BsBq3LSVhjI2ETXfcytw== + dependencies: + "@babel/runtime" "^7.20.13" + "@react-pdf/types" "^2.3.4" + cross-fetch "^3.1.5" + fontkit "^2.0.2" + is-url "^1.2.4" + +"@react-pdf/image@^2.2.2": + version "2.2.2" + resolved "https://registry.yarnpkg.com/@react-pdf/image/-/image-2.2.2.tgz#e6fa630210583f76c5f1fd4e3059528d6bededac" + integrity sha512-990JvRZuhsnHyAGd7gvmhfr+4/5PAHLH9IgDstaEDLEq2eFAIQFuNM7k3D6kjKgV1mM7Jqif3CWqrcHBF3jrJw== + dependencies: + "@babel/runtime" "^7.20.13" + "@react-pdf/png-js" "^2.2.0" + cross-fetch "^3.1.5" + +"@react-pdf/layout@^3.6.3": + version "3.6.3" + resolved "https://registry.yarnpkg.com/@react-pdf/layout/-/layout-3.6.3.tgz#6f108d0910bed7ba02619cbb4d0393ba72411e8c" + integrity sha512-w6ACZ9o18Q5wbzsY9a4KW2Gqn6Drt3AN/kb/I6SBz/L7PAJ9rPQBIDq/s5qZJ+/WwWy33rcC8WC1givtDhjCHQ== + dependencies: + "@babel/runtime" "^7.20.13" + "@react-pdf/fns" "2.0.1" + "@react-pdf/image" "^2.2.2" + "@react-pdf/pdfkit" "^3.0.2" + "@react-pdf/primitives" "^3.0.0" + "@react-pdf/stylesheet" "^4.1.8" + "@react-pdf/textkit" "^4.2.0" + "@react-pdf/types" "^2.3.4" + "@react-pdf/yoga" "^4.1.2" + cross-fetch "^3.1.5" + emoji-regex "^10.2.1" + queue "^6.0.1" + +"@react-pdf/pdfkit@^3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@react-pdf/pdfkit/-/pdfkit-3.0.2.tgz#6ec17f416f464d86c06c0b0d8a76ea9acdff9ddb" + integrity sha512-+m5rwNCwyEH6lmnZWpsQJvdqb6YaCCR0nMWrc/KKDwznuPMrGmGWyNxqCja+bQPORcHZyl6Cd/iFL0glyB3QGw== + dependencies: + "@babel/runtime" "^7.20.13" + "@react-pdf/png-js" "^2.2.0" + browserify-zlib "^0.2.0" + crypto-js "^4.0.0" + fontkit "^2.0.2" + vite-compatible-readable-stream "^3.6.1" + +"@react-pdf/png-js@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@react-pdf/png-js/-/png-js-2.2.0.tgz#c40ec2ae745f2feb7bd557024af8f366c2c8c00e" + integrity sha512-csZU5lfNW73tq7s7zB/1rWXGro+Z9cQhxtsXwxS418TSszHUiM6PwddouiKJxdGhbVLjRIcuuFVa0aR5cDOC6w== + dependencies: + browserify-zlib "^0.2.0" + +"@react-pdf/primitives@^3.0.0": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@react-pdf/primitives/-/primitives-3.0.1.tgz#3b2bfebdb1fef6fc7f99214ccfd0932267b8e0cd" + integrity sha512-0HGcknrLNwyhxe+SZCBL29JY4M85mXKdvTZE9uhjNbADGgTc8wVnkc5+e4S/lDvugbVISXyuIhZnYwtK9eDnyQ== + +"@react-pdf/render@^3.2.7": + version "3.2.7" + resolved "https://registry.yarnpkg.com/@react-pdf/render/-/render-3.2.7.tgz#3b2a479da336531f9b6358ff9beabb18dc282106" + integrity sha512-fAgbbAAkVL0hpcf1vUJLHxuPjPBqZuq8nors7fCwvoatBBwOWP9fza7IDPeFKN7+ZOnfmIZzes8Kc/DNHzJohw== + dependencies: + "@babel/runtime" "^7.20.13" + "@react-pdf/fns" "2.0.1" + "@react-pdf/primitives" "^3.0.0" + "@react-pdf/textkit" "^4.2.0" + "@react-pdf/types" "^2.3.4" + abs-svg-path "^0.1.1" + color-string "^1.5.3" + normalize-svg-path "^1.1.0" + parse-svg-path "^0.1.2" + svg-arc-to-cubic-bezier "^3.2.0" + +"@react-pdf/renderer@^3.1.14": + version "3.1.14" + resolved "https://registry.yarnpkg.com/@react-pdf/renderer/-/renderer-3.1.14.tgz#37c4bc63db1b998faba594a05c5accc9a7ebd85b" + integrity sha512-Qk29uTamH6q+drK/YmiFbuQQ+yutesfIe+wyrsXFoUJUutIiDIaibO6zByMkhWb3M6CMt6NvG3NLHio1OF8U6Q== + dependencies: + "@babel/runtime" "^7.20.13" + "@react-pdf/font" "^2.3.7" + "@react-pdf/layout" "^3.6.3" + "@react-pdf/pdfkit" "^3.0.2" + "@react-pdf/primitives" "^3.0.0" + "@react-pdf/render" "^3.2.7" + "@react-pdf/types" "^2.3.4" + events "^3.3.0" + object-assign "^4.1.1" + prop-types "^15.6.2" + queue "^6.0.1" + scheduler "^0.17.0" + +"@react-pdf/stylesheet@^4.1.8": + version "4.1.8" + resolved "https://registry.yarnpkg.com/@react-pdf/stylesheet/-/stylesheet-4.1.8.tgz#17e0d36cdb767a2566cfc59786dca03af03468cb" + integrity sha512-/EuB9RBsH3YYRj8mwzImaul619MvX3rsHNF4h8LnlwDOuBehPA3L/fHrikfPqtJvHqK2ty3GXnkw0HG5SQpMzw== + dependencies: + "@babel/runtime" "^7.20.13" + "@react-pdf/fns" "2.0.1" + "@react-pdf/types" "^2.3.4" + color-string "^1.5.3" + hsl-to-hex "^1.0.0" + media-engine "^1.0.3" + postcss-value-parser "^4.1.0" + +"@react-pdf/textkit@^4.2.0": + version "4.2.0" + resolved "https://registry.yarnpkg.com/@react-pdf/textkit/-/textkit-4.2.0.tgz#bd8299708ddb7a9b154706aa2516dd3666230cf1" + integrity sha512-R90pEOW6NdhUx4p99iROvKmwB06IRYdXMhh0QcmUeoPOLe64ZdMfs3LZliNUWgI5fCmq71J+nv868i/EakFPDg== + dependencies: + "@babel/runtime" "^7.20.13" + "@react-pdf/fns" "2.0.1" + hyphen "^1.6.4" + unicode-properties "^1.4.1" + +"@react-pdf/types@^2.3.4": + version "2.3.4" + resolved "https://registry.yarnpkg.com/@react-pdf/types/-/types-2.3.4.tgz#6a1ce0e5b65a4bebaaa7b45777265792df06c5e9" + integrity sha512-vGGz21BTE05EktBbotbd7fjC0Yi8A/lOSIpzd7L7aF1XY+vyIHlQVb35DWCipM1p/6XN4cr9etGAmm1e4Mtmjw== + +"@react-pdf/yoga@^4.1.2": + version "4.1.2" + resolved "https://registry.yarnpkg.com/@react-pdf/yoga/-/yoga-4.1.2.tgz#cc901f7384f0c1976d7ddeba5cc77e26d768ba77" + integrity sha512-OlMZkFrJDj4GyKZ70thiObwwPVZ52B7mlPyfzwa+sgwsioqHXg9nMWOO+7SQFNUbbOGagMUu0bCuTv+iXYZuaQ== + dependencies: + "@babel/runtime" "^7.20.13" + "@rushstack/eslint-patch@^1.1.3": version "1.2.0" resolved "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz" @@ -1031,6 +1176,14 @@ dependencies: tslib "^2.4.0" +"@swc/helpers@^0.4.2": + version "0.4.36" + resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.4.36.tgz#fcfff76ed52c214f357e8e9d3f37b568908072d9" + integrity sha512-5lxnyLEYFskErRPenYItLRSge5DjrJngYKdVjRSrWfza9G6KkgHEXi0vUZiyUeMU5JfXH1YnvXZzSp8ul88o2Q== + dependencies: + legacy-swc-helpers "npm:@swc/helpers@=0.4.14" + tslib "^2.4.0" + "@tanstack/react-table@^8.10.1": version "8.10.1" resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.10.1.tgz#f3e7d6e3f82dd43947e8893617a3c50e9e3fa383" @@ -1055,6 +1208,13 @@ dependencies: "@types/node" "*" +"@types/blob-stream@^0.1.33": + version "0.1.33" + resolved "https://registry.yarnpkg.com/@types/blob-stream/-/blob-stream-0.1.33.tgz#2107fc2e9ec11a70161dec982e62858e8937b4d3" + integrity sha512-HNHZ1S6W7F8PhxdyAastunpUC8cAZim78UIfqbL79gLzylp8EZep68yxAh11hTRoEvsqHAg/MECgmKF8+V0HzQ== + dependencies: + "@types/node" "*" + "@types/body-parser@*": version "1.19.2" resolved "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz" @@ -1435,6 +1595,11 @@ abort-controller@^3.0.0: dependencies: event-target-shim "^5.0.0" +abs-svg-path@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/abs-svg-path/-/abs-svg-path-0.1.1.tgz#df601c8e8d2ba10d4a76d625e236a9a39c2723bf" + integrity sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA== + acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" @@ -1674,7 +1839,7 @@ balanced-match@^1.0.0: resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== -base64-js@^1.3.0, base64-js@^1.3.1: +base64-js@^1.1.2, base64-js@^1.3.0, base64-js@^1.3.1: version "1.5.1" resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== @@ -1697,6 +1862,18 @@ binary-extensions@^2.0.0: resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== +blob-stream@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/blob-stream/-/blob-stream-0.1.3.tgz#98d668af6996e0f32ef666d06e215ccc7d77686c" + integrity sha512-xXwyhgVmPsFVFFvtM5P0syI17/oae+MIjLn5jGhuD86mmSJ61EWMWmbPrV/0+bdcH9jQ2CzIhmTQKNUJL7IPog== + dependencies: + blob "0.0.4" + +blob@0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.4.tgz#bcf13052ca54463f30f9fc7e95b9a47630a94921" + integrity sha512-YRc9zvVz4wNaxcXmiSgb9LAg7YYwqQ2xd0Sj6osfA7k/PKmIGVlnOYs3wOFdkRC9/JpQu8sGt/zHgJV7xzerfg== + bluebird@^3.7.2: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" @@ -1724,6 +1901,20 @@ braces@^3.0.2, braces@~3.0.2: dependencies: fill-range "^7.0.1" +brotli@^1.3.2: + version "1.3.3" + resolved "https://registry.yarnpkg.com/brotli/-/brotli-1.3.3.tgz#7365d8cc00f12cf765d2b2c898716bcf4b604d48" + integrity sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg== + dependencies: + base64-js "^1.1.2" + +browserify-zlib@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.2.0.tgz#2869459d9aa3be245fe8fe2ca1f46e2e7f54d73f" + integrity sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA== + dependencies: + pako "~1.0.5" + browserslist@^4.21.5: version "4.21.5" resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.21.5.tgz" @@ -1840,6 +2031,11 @@ cliui@^7.0.2: strip-ansi "^6.0.0" wrap-ansi "^7.0.0" +clone@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" + integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w== + clsx@^1.1.1, clsx@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz" @@ -1864,11 +2060,19 @@ color-name@1.1.3: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== -color-name@^1.1.4, color-name@~1.1.4: +color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4: version "1.1.4" resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +color-string@^1.5.3: + version "1.9.1" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4" + integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg== + dependencies: + color-name "^1.0.0" + simple-swizzle "^0.2.2" + color-support@^1.1.2: version "1.1.3" resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" @@ -1944,6 +2148,13 @@ country-flag-icons@^1.5.4: resolved "https://registry.yarnpkg.com/country-flag-icons/-/country-flag-icons-1.5.7.tgz#f1f2ddf14f3cbf01cba6746374aeba94db35d4b4" integrity sha512-AdvXhMcmSp7nBSkpGfW4qR/luAdRUutJqya9PuwRbsBzuoknThfultbv7Ib6fWsHXC43Es/4QJ8gzQQdBNm75A== +cross-fetch@^3.1.5: + version "3.1.8" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.8.tgz#0327eba65fd68a7d119f8fb2bf9334a1a7956f82" + integrity sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg== + dependencies: + node-fetch "^2.6.12" + cross-spawn@^6.0.5: version "6.0.5" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" @@ -1964,6 +2175,11 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" +crypto-js@^4.0.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" + integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== + css-selector-tokenizer@^0.8: version "0.8.0" resolved "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.8.0.tgz" @@ -2093,6 +2309,11 @@ dezalgo@^1.0.4: asap "^2.0.0" wrappy "1" +dfa@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/dfa/-/dfa-1.2.0.tgz#96ac3204e2d29c49ea5b57af8d92c2ae12790657" + integrity sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q== + didyoumean@^1.2.2: version "1.2.2" resolved "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz" @@ -2169,6 +2390,11 @@ electron-to-chromium@^1.4.284: resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.368.tgz" integrity sha512-e2aeCAixCj9M7nJxdB/wDjO6mbYX+lJJxSJCXDzlr5YPGYVofuJwGN9nKg2o6wWInjX6XmxRinn3AeJMK81ltw== +emoji-regex@^10.2.1: + version "10.3.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.3.0.tgz#76998b9268409eb3dae3de989254d456e70cfe23" + integrity sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw== + emoji-regex@^8.0.0: version "8.0.0" resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz" @@ -2567,6 +2793,11 @@ event-target-shim@^5.0.0: resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== +events@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + express-handlebars@^7.1.2: version "7.1.2" resolved "https://registry.yarnpkg.com/express-handlebars/-/express-handlebars-7.1.2.tgz#2471673d11af46f496cba4098a705f0217232fda" @@ -2739,6 +2970,21 @@ follow-redirects@^1.15.0: resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz" integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== +fontkit@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/fontkit/-/fontkit-2.0.2.tgz#ac5384f3ecab8327c6d2ea2e4d384afc544b48fd" + integrity sha512-jc4k5Yr8iov8QfS6u8w2CnHWVmbOGtdBtOXMze5Y+QD966Rx6PEVWXSEGwXlsDlKtu1G12cJjcsybnqhSk/+LA== + dependencies: + "@swc/helpers" "^0.4.2" + brotli "^1.3.2" + clone "^2.1.2" + dfa "^1.2.0" + fast-deep-equal "^3.1.3" + restructure "^3.0.0" + tiny-inflate "^1.0.3" + unicode-properties "^1.4.0" + unicode-trie "^2.0.0" + for-each@^0.3.3: version "0.3.3" resolved "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz" @@ -3166,6 +3412,18 @@ howler@^2.2.4: resolved "https://registry.yarnpkg.com/howler/-/howler-2.2.4.tgz#bd3df4a4f68a0118a51e4bd84a2bfc2e93e6e5a1" integrity sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w== +hsl-to-hex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz#c58c826dc6d2f1e0a5ff1da5a7ecbf03faac1352" + integrity sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA== + dependencies: + hsl-to-rgb-for-reals "^1.1.0" + +hsl-to-rgb-for-reals@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz#e1eb23f6b78016e3722431df68197e6dcdc016d9" + integrity sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg== + http-parser-js@>=0.5.1: version "0.5.8" resolved "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz" @@ -3193,6 +3451,11 @@ husky@^8.0.3: resolved "https://registry.yarnpkg.com/husky/-/husky-8.0.3.tgz#4936d7212e46d1dea28fef29bb3a108872cd9184" integrity sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg== +hyphen@^1.6.4: + version "1.9.1" + resolved "https://registry.yarnpkg.com/hyphen/-/hyphen-1.9.1.tgz#84e3ab0d06b9223b9cefd09cc1b5b52e2f661401" + integrity sha512-fIPVvM6BUW+878xne+wwIcBjMxeKpoADmxNTjKMocUQWiGOvwyEfZEG95IeL/t4Su6nbfbXeYDUnz62pxzLPmw== + idb@7.0.1: version "7.0.1" resolved "https://registry.npmjs.org/idb/-/idb-7.0.1.tgz" @@ -3229,7 +3492,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.3: +inherits@2, inherits@^2.0.3, inherits@~2.0.3: version "2.0.4" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -3292,6 +3555,11 @@ is-arrayish@^0.2.1: resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== +is-arrayish@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" + integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== + is-bigint@^1.0.1: version "1.0.4" resolved "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz" @@ -3444,6 +3712,11 @@ is-typed-array@^1.1.10, is-typed-array@^1.1.9: gopd "^1.0.1" has-tostringtag "^1.0.0" +is-url@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/is-url/-/is-url-1.2.4.tgz#04a4df46d28c4cff3d73d01ff06abeb318a1aa52" + integrity sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww== + is-weakmap@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz" @@ -3663,6 +3936,13 @@ language-tags@=1.0.5: dependencies: language-subtag-registry "~0.3.2" +"legacy-swc-helpers@npm:@swc/helpers@=0.4.14": + version "0.4.14" + resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.4.14.tgz#1352ac6d95e3617ccb7c1498ff019654f1e12a74" + integrity sha512-4C7nX/dvpzB7za4Ql9K81xK3HPxCpHMgwTZVyf+9JQ6VUbn9jjZVN7/Nkdz/Ugzs2CSjqnL/UPXroiVBVHUWUw== + dependencies: + tslib "^2.4.0" + levn@^0.4.1: version "0.4.1" resolved "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz" @@ -3851,6 +4131,11 @@ mdurl@^1.0.1: resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" integrity sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g== +media-engine@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/media-engine/-/media-engine-1.0.3.tgz#be3188f6cd243ea2a40804a35de5a5b032f58dad" + integrity sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg== + memoize-one@^5.1.1: version "5.2.1" resolved "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz" @@ -4028,7 +4313,7 @@ node-fetch@2.6.7: dependencies: whatwg-url "^5.0.0" -node-fetch@^2.6.1, node-fetch@^2.6.7, node-fetch@^2.6.9: +node-fetch@^2.6.1, node-fetch@^2.6.12, node-fetch@^2.6.7, node-fetch@^2.6.9: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== @@ -4072,6 +4357,13 @@ normalize-range@^0.1.2: resolved "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz" integrity sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA== +normalize-svg-path@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz#0e614eca23c39f0cffe821d6be6cd17e569a766c" + integrity sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg== + dependencies: + svg-arc-to-cubic-bezier "^3.0.0" + npmlog@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-5.0.1.tgz#f06678e80e29419ad67ab964e0fa69959c1eb8b0" @@ -4209,6 +4501,16 @@ p-locate@^5.0.0: dependencies: p-limit "^3.0.2" +pako@^0.2.5: + version "0.2.9" + resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75" + integrity sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA== + +pako@~1.0.5: + version "1.0.11" + resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" + integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== + parent-module@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz" @@ -4226,6 +4528,11 @@ parse-json@^5.0.0: json-parse-even-better-errors "^2.3.0" lines-and-columns "^1.1.6" +parse-svg-path@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/parse-svg-path/-/parse-svg-path-0.1.2.tgz#7a7ec0d1eb06fa5325c7d3e009b859a09b5d49eb" + integrity sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ== + path-exists@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz" @@ -4323,7 +4630,7 @@ postcss-selector-parser@^6.0.10, postcss-selector-parser@^6.0.11: cssesc "^3.0.0" util-deprecate "^1.0.2" -postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0: +postcss-value-parser@^4.0.0, postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0: version "4.2.0" resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== @@ -4509,6 +4816,13 @@ queue-microtask@^1.2.2: resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +queue@^6.0.1: + version "6.0.2" + resolved "https://registry.yarnpkg.com/queue/-/queue-6.0.2.tgz#b91525283e2315c7553d2efa18d83e76432fed65" + integrity sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA== + dependencies: + inherits "~2.0.3" + quick-lru@^5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz" @@ -4776,6 +5090,11 @@ resolve@^2.0.0-next.4: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" +restructure@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/restructure/-/restructure-3.0.0.tgz#a55031d7ed3314bf585f815836fff9da3d65101d" + integrity sha512-Xj8/MEIhhfj9X2rmD9iJ4Gga9EFqVlpMj3vfLnV2r/Mh5jRMryNV+6lWh9GdJtDBcBSPIqzRdfBQ3wDtNFv/uw== + retry-request@^5.0.0: version "5.0.2" resolved "https://registry.yarnpkg.com/retry-request/-/retry-request-5.0.2.tgz#143d85f90c755af407fcc46b7166a4ba520e44da" @@ -4822,6 +5141,14 @@ safe-regex-test@^1.0.0: get-intrinsic "^1.1.3" is-regex "^1.1.4" +scheduler@^0.17.0: + version "0.17.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.17.0.tgz#7c9c673e4ec781fac853927916d1c426b6f3ddfe" + integrity sha512-7rro8Io3tnCPuY4la/NuI5F2yfESpnfZyT6TtkXnSWVkcu0BCDJ+8gk5ozUaFaxpIyNuWAPXrH0yFcSi28fnDA== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + scheduler@^0.23.0: version "0.23.0" resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz" @@ -4916,6 +5243,13 @@ signal-exit@^4.0.1: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== +simple-swizzle@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" + integrity sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg== + dependencies: + is-arrayish "^0.3.1" + slash@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz" @@ -5119,6 +5453,11 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +svg-arc-to-cubic-bezier@^3.0.0, svg-arc-to-cubic-bezier@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz#390c450035ae1c4a0104d90650304c3bc814abe6" + integrity sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g== + swr@^2.1.3: version "2.1.3" resolved "https://registry.npmjs.org/swr/-/swr-2.1.3.tgz" @@ -5229,6 +5568,11 @@ tiny-glob@^0.2.9: globalyzer "0.1.0" globrex "^0.1.2" +tiny-inflate@^1.0.0, tiny-inflate@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz#122715494913a1805166aaf7c93467933eea26c4" + integrity sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw== + tmp@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14" @@ -5360,6 +5704,22 @@ undici-types@~5.25.1: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.25.3.tgz#e044115914c85f0bcbb229f346ab739f064998c3" integrity sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA== +unicode-properties@^1.4.0, unicode-properties@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/unicode-properties/-/unicode-properties-1.4.1.tgz#96a9cffb7e619a0dc7368c28da27e05fc8f9be5f" + integrity sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg== + dependencies: + base64-js "^1.3.0" + unicode-trie "^2.0.0" + +unicode-trie@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/unicode-trie/-/unicode-trie-2.0.0.tgz#8fd8845696e2e14a8b67d78fa9e0dd2cad62fec8" + integrity sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ== + dependencies: + pako "^0.2.5" + tiny-inflate "^1.0.0" + update-browserslist-db@^1.0.10: version "1.0.11" resolved "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz" @@ -5407,6 +5767,15 @@ uuid@^9.0.0: resolved "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz" integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg== +vite-compatible-readable-stream@^3.6.1: + version "3.6.1" + resolved "https://registry.yarnpkg.com/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz#27267aebbdc9893c0ddf65a421279cbb1e31d8cd" + integrity sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + warning@^4.0.2: version "4.0.3" resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" From 42fe650ae6fbd38dbff35fde2cf4a351a3dc6d95 Mon Sep 17 00:00:00 2001 From: Joao Ramos Date: Thu, 4 Jan 2024 12:18:11 +0000 Subject: [PATCH 02/29] PDF Styling improvements --- src/exams/pdf/index.tsx | 197 ++++++++++++++++++++++++++++------------ 1 file changed, 137 insertions(+), 60 deletions(-) diff --git a/src/exams/pdf/index.tsx b/src/exams/pdf/index.tsx index 02adb77c..4b3841fe 100644 --- a/src/exams/pdf/index.tsx +++ b/src/exams/pdf/index.tsx @@ -1,11 +1,19 @@ import React from "react"; -import { Document, Page, View, Text, StyleSheet } from "@react-pdf/renderer"; +import { + Document, + Page, + View, + Text, + StyleSheet, +} from "@react-pdf/renderer"; import ProgressBar from "./progress.bar"; +// import RadialProgress from "./radial.progress"; +// import RadialProgressSvg from "./radial.progress.svg"; const styles = StyleSheet.create({ body: { paddingTop: 35, - paddingBottom: 65, + paddingBottom: 20, paddingHorizontal: 35, }, titleView: { @@ -17,29 +25,28 @@ const styles = StyleSheet.create({ textTransform: "uppercase", }, textPadding: { - margin: "16px", - }, - userSection: { - fontWeight: "bold", + margin: "8px", }, separator: { width: "100%", - borderBottom: "1px solid blue", + borderBottom: "1px solid #89b0c2", + }, + textFont: { + fontFamily: "Helvetica", + }, + textBold: { + fontWeight: "bold", }, textColor: { - color: "blue", + color: "#4e4969", }, textUnderline: { textDecoration: "underline", }, - skillsTitle: { - fontSize: 14, - }, - skillsText: { - fontSize: 12, - }, - footerText: { - fontSize: 9, + spacedRow: { + display: "flex", + flexDirection: "row", + justifyContent: "space-between", }, }); interface Props { @@ -51,36 +58,81 @@ interface Props { } const PDFReport = ({ date, name, email, id, gender }: Props) => { + const defaultTextStyle = [styles.textFont, { fontSize: 8 }]; + const defaultSkillsTextStyle = [styles.textFont, { fontSize: 8 }]; + const defaultSkillsTitleStyle = [ + styles.textFont, + styles.textColor, + styles.textBold, + { fontSize: 7 }, + ]; return ( - + English Skills Test Result Report - Date of Test: {date} + Date of Test: {date} - Candidate Information: + + Candidate Information: + - Name: {name} - ID: {id} - Email: {email} - Gender: {gender} + Name: {name} + ID: {id} + Email: {email} + Gender: {gender} - Test Details: + + Test Details: + - Performance Summary + + Performance Summary + - + Skills Feedback { paddingTop: 10, }} > - - Listening - - xxx - Reading - xxx - Writing - xxx - Speaking - xxx + Listening + xxx + Reading + xxx + Writing + xxx + Speaking + xxx - - - Validity - - This report remains valid for a duration of three months from the - test date. Confidential – circulated for concern people - - Declaration - - We hereby declare that exam results on our platform, assessed by AI, - are not the sole determinants of candidates' English - proficiency levels. While EnCoach provides feedback based on - assessments, we recognize that language proficiency encompasses - practical application, cultural understanding, and real-life - communication. We urge users to consider exam results as a measure - of progress and improvement, and we continuously enhance our system - to ensure accuracy and reliability. - + - - info@encoach.com + + + + + + Validity + + This report remains valid for a duration of three months from + the test date. + + + + Confidential – circulated for concern people + + + + Declaration + + We hereby declare that exam results on our platform, assessed by + AI, are not the sole determinants of candidates' English + proficiency levels. While EnCoach provides feedback based on + assessments, we recognize that language proficiency encompasses + practical application, cultural understanding, and real-life + communication. We urge users to consider exam results as a measure + of progress and improvement, and we continuously enhance our + system to ensure accuracy and reliability. + + + + info@encoach.com https://encoach.com - Group ID: TRI64BNBOIU5043 + + Group ID: TRI64BNBOIU5043 + + `${pageNumber} / ${totalPages}` + } + fixed + /> + From 227de4ffc47db4a43c2161c6a2b4d96a764fb2a5 Mon Sep 17 00:00:00 2001 From: Joao Ramos Date: Thu, 4 Jan 2024 12:18:34 +0000 Subject: [PATCH 03/29] Fixed Date String for PDF --- src/pages/api/stats/[id]/export.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/api/stats/[id]/export.tsx b/src/pages/api/stats/[id]/export.tsx index 744a993c..607801dd 100644 --- a/src/pages/api/stats/[id]/export.tsx +++ b/src/pages/api/stats/[id]/export.tsx @@ -72,7 +72,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) { const fileRef = ref(storage, `exam_report/${fileName}`); const pdfStream = await ReactPDF.renderToStream( Date: Thu, 4 Jan 2024 15:48:30 +0000 Subject: [PATCH 04/29] Updated export to now work based on session --- src/pages/api/stats/[id]/export.tsx | 145 ++++++++++++++++++---------- 1 file changed, 94 insertions(+), 51 deletions(-) diff --git a/src/pages/api/stats/[id]/export.tsx b/src/pages/api/stats/[id]/export.tsx index 607801dd..a9a08b28 100644 --- a/src/pages/api/stats/[id]/export.tsx +++ b/src/pages/api/stats/[id]/export.tsx @@ -6,6 +6,10 @@ import { getDoc, deleteDoc, updateDoc, + getDocs, + query, + collection, + where, } from "firebase/firestore"; import { withIronSessionApiRoute } from "iron-session/next"; import { sessionOptions } from "@/lib/session"; @@ -20,7 +24,7 @@ import { import blobStream from "blob-stream"; import { Stat } from "@/interfaces/user"; import { User } from "@/interfaces/user"; - +import { Module } from "@/interfaces"; const db = getFirestore(app); export default withIronSessionApiRoute(handler, sessionOptions); @@ -30,71 +34,110 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { } export const streamToBuffer = async ( - stream: NodeJS.ReadableStream, - ): Promise => { - return new Promise((resolve, reject) => { + stream: NodeJS.ReadableStream +): Promise => { + return new Promise((resolve, reject) => { const chunks: Buffer[] = []; - stream.on('data', (data) => { + stream.on("data", (data) => { chunks.push(data); }); - stream.on('end', () => { + stream.on("end", () => { resolve(Buffer.concat(chunks)); }); - stream.on('error', reject); - }); - }; + stream.on("error", reject); + }); +}; + +interface ModuleScore { + score: number; + total: number; + module: Module; +} async function post(req: NextApiRequest, res: NextApiResponse) { // debugger; + debugger; if (req.session.user) { const { id } = req.query as { id: string }; + // const codeCheckerRef = await getDocs( + // query(collection(db, "codes"), where("checkout", "==", checkout)) + // ); - const docRef = doc(db, "stats", id); - const docSnap = await getDoc(docRef); + // const docRef = doc(db, "stats", id).where; + // const docSnap = await getDoc(docRef); - if (docSnap.exists()) { - const stat = docSnap.data() as Stat; + const docsSnap = await getDocs( + query( + collection(db, "stats"), + where("session", "==", id), + where("user", "==", req.session.user.id) + ) + ); - if (stat.user !== req.session.user.id) { - res.status(401).json(undefined); - return; - } - - // if (stat.pdf) { - // res.status(200).end(docSnap.pdf); - // return; - // } - const docUser = await getDoc(doc(db, "users", stat.user)); - - if (docUser.exists()) { - const user = docUser.data() as User; - const fileName = `${Date.now().toString()}.pdf`; - const fileRef = ref(storage, `exam_report/${fileName}`); - const pdfStream = await ReactPDF.renderToStream( - - ); - - const pdfBuffer = await streamToBuffer(pdfStream); - const snapshot = await uploadBytes(fileRef, pdfBuffer, { - contentType: 'application/pdf', - }); - await updateDoc(docRef, { - pdf: snapshot.ref.fullPath, - }); - res.status(200).end(snapshot.ref.fullPath); - return; - } + if (docsSnap.empty) { + res.status(404).end(); + return; } - res.status(500).json({ ok: false }); - return; + const docUser = await getDoc(doc(db, "users", req.session.user.id)); + + if (docUser.exists()) { + const user = docUser.data() as User; + + const stats = docsSnap.docs.map((d) => d.data()); + const results = stats.reduce((accm: ModuleScore[], { module, score }) => { + if (accm.find((e: ModuleScore) => e.module === module)) { + return accm.map((e: ModuleScore) => { + if (e.module === module) { + return { + ...e, + score: e.score + score.correct, + total: e.total + score.total, + }; + } + + return e; + }); + } + + return [ + ...accm, + { + module, + score: score.correct, + total: score.total, + }, + ]; + }, []); + + const [stat] = stats as Stat[]; + + const fileName = `${Date.now().toString()}.pdf`; + const fileRef = ref(storage, `exam_report/${fileName}`); + const pdfStream = await ReactPDF.renderToStream( + + ); + + const pdfBuffer = await streamToBuffer(pdfStream); + const snapshot = await uploadBytes(fileRef, pdfBuffer, { + contentType: "application/pdf", + }); + docsSnap.docs.forEach(async (doc) => { + await updateDoc(doc.ref, { + pdf: snapshot.ref.fullPath, + }); + }); + res.status(200).end(snapshot.ref.fullPath); + return; + } } - res.status(401).json(undefined); + res.status(500).json({ ok: false }); + return; } From a4f79d236d339e3c56cb30b0e82604019a8596d5 Mon Sep 17 00:00:00 2001 From: Joao Ramos Date: Thu, 4 Jan 2024 19:19:21 +0000 Subject: [PATCH 05/29] Fixed Logo Modules results display --- src/exams/pdf/index.tsx | 82 +++++++++++++++++++--- src/interfaces/module.scores.ts | 8 +++ src/pages/api/stats/[id]/export.tsx | 102 +++++++++++++++++++++++++--- 3 files changed, 170 insertions(+), 22 deletions(-) create mode 100644 src/interfaces/module.scores.ts diff --git a/src/exams/pdf/index.tsx b/src/exams/pdf/index.tsx index 4b3841fe..2a40147b 100644 --- a/src/exams/pdf/index.tsx +++ b/src/exams/pdf/index.tsx @@ -1,3 +1,4 @@ +/* eslint-disable jsx-a11y/alt-text */ import React from "react"; import { Document, @@ -5,14 +6,18 @@ import { View, Text, StyleSheet, + Image, } from "@react-pdf/renderer"; import ProgressBar from "./progress.bar"; // import RadialProgress from "./radial.progress"; // import RadialProgressSvg from "./radial.progress.svg"; +import { Module } from "@/interfaces"; +import { ModuleScore } from "@/interfaces/module.scores"; +// import logo from './logo_title.png'; const styles = StyleSheet.create({ body: { - paddingTop: 35, + paddingTop: 10, paddingBottom: 20, paddingHorizontal: 35, }, @@ -35,6 +40,7 @@ const styles = StyleSheet.create({ fontFamily: "Helvetica", }, textBold: { + fontFamily: "Helvetica-Bold", fontWeight: "bold", }, textColor: { @@ -49,15 +55,28 @@ const styles = StyleSheet.create({ justifyContent: "space-between", }, }); + interface Props { date: string; name: string; email: string; id: string; gender?: string; + testDetails: ModuleScore[]; + summary: string; + logo: string; } -const PDFReport = ({ date, name, email, id, gender }: Props) => { +const PDFReport = ({ + date, + name, + email, + id, + gender, + testDetails, + summary, + logo, +}: Props) => { const defaultTextStyle = [styles.textFont, { fontSize: 8 }]; const defaultSkillsTextStyle = [styles.textFont, { fontSize: 8 }]; const defaultSkillsTitleStyle = [ @@ -69,6 +88,18 @@ const PDFReport = ({ date, name, email, id, gender }: Props) => { return ( + + + { > Test Details: + + {testDetails.map(({ module, score, total }) => ( + + + {module} + + {score} + Out of {total} + + ))} + { Performance Summary - + {summary} @@ -138,16 +195,19 @@ const PDFReport = ({ date, name, email, id, gender }: Props) => { - Listening - xxx - Reading - xxx - Writing - xxx - Speaking - xxx + {testDetails + .filter(({ feedback }) => feedback) + .map(({ module, feedback }) => ( + + + {module} + + {feedback} + + ))} diff --git a/src/interfaces/module.scores.ts b/src/interfaces/module.scores.ts new file mode 100644 index 00000000..cd4b0ea2 --- /dev/null +++ b/src/interfaces/module.scores.ts @@ -0,0 +1,8 @@ +import {Module} from "@/interfaces"; + +export interface ModuleScore { + score: number; + total: number; + module: Module | 'Overall'; + feedback?: string, + } \ No newline at end of file diff --git a/src/pages/api/stats/[id]/export.tsx b/src/pages/api/stats/[id]/export.tsx index a9a08b28..b4d8838c 100644 --- a/src/pages/api/stats/[id]/export.tsx +++ b/src/pages/api/stats/[id]/export.tsx @@ -25,6 +25,9 @@ import blobStream from "blob-stream"; import { Stat } from "@/interfaces/user"; import { User } from "@/interfaces/user"; import { Module } from "@/interfaces"; +import { ModuleScore } from "@/interfaces/module.scores"; +import fs from 'fs'; +import path from 'path'; const db = getFirestore(app); export default withIronSessionApiRoute(handler, sessionOptions); @@ -48,15 +51,75 @@ export const streamToBuffer = async ( }); }; -interface ModuleScore { - score: number; - total: number; - module: Module; -} +const getExamSummary = (score: number) => { + if (score > 0.8) { + return "Scoring between 81% and 100% on the English exam demonstrates an outstanding level of proficiency in writing, speaking, listening, and reading. Mastery of key concepts is evident across all language domains, showcasing not only a high level of skill but also a dedication to excellence. Continuing to challenge oneself with advanced material in writing, speaking, listening, and reading will further refine the already impressive command of the English language."; + } + if (score > 0.6) { + return "Scoring between 61% and 80% on the English exam, encompassing writing, speaking, listening, and reading, reflects a commendable level of proficiency in each domain. There's evidence of a solid grasp of key concepts, and effective application of skills. Room for refinement and deeper exploration in writing, speaking, listening, and reading remains, presenting an opportunity for further mastery."; + } + + if (score > 0.4) { + return "Scoring between 41% and 60% on the English exam across writing, speaking, listening, and reading demonstrates a moderate level of understanding in each domain. While there's a commendable grasp of key concepts, refining fundamental skills in writing, speaking, listening, and reading can lead to notable improvement. Consistent effort and targeted focus on weaker areas are recommended."; + } + + if (score > 0.2) { + return "Scoring between 21% and 40% on the English exam, spanning writing, speaking, listening, and reading, indicates some understanding of key concepts in each domain. However, there's room for improvement in fundamental skills. Strengthening writing, speaking, listening, and reading abilities through consistent effort and focused study will contribute to overall proficiency."; + } + + return "This student's performance on the English exam, encompassing writing, speaking, listening, and reading, reflects a significant need for improvement, scoring between 0% and 20%. There's a notable gap in understanding key concepts across all language domains. Strengthening fundamental skills in writing, speaking, listening, and reading is crucial. Developing a consistent study routine and seeking additional support in each area can contribute to substantial progress."; +}; + +const getLevelSummary = (score: number) => { + if (score > 0.8) { + return "Scoring between 81% and 100% on the English exam showcases an outstanding level of understanding and proficiency. Your performance reflects a mastery of key concepts, including grammar, vocabulary, and comprehension. You exhibit a high level of skill in applying these elements effectively. Your dedication to excellence is evident, and your consistent, stellar performance is commendable. Continue to challenge yourself with advanced material to further refine your already impressive command of the English language. Your commitment to excellence positions you as a standout student in English studies, and your achievements are a testament to your hard work and capability."; + } + + if (score > 0.6) { + return "Scoring between 61% and 80% on the English exam reflects a commendable level of understanding and proficiency. You have demonstrated a solid grasp of key concepts, including grammar, vocabulary, and comprehension. There's evidence of effective application of skills, but room for refinement and deeper exploration remains. Consistent effort in honing nuanced aspects of language will contribute to even greater mastery. Continue engaging with challenging material and seeking opportunities for advanced comprehension. With sustained dedication, you have the potential to elevate your performance to an exceptional level and further excel in your English studies."; + } + + if (score > 0.4) { + return "Scoring between 41% and 60% on the English exam reflects a moderate level of understanding. You demonstrate a grasp of some key concepts, but there's room for refinement in areas like grammar, vocabulary, and comprehension. Consistent effort and a strategic focus on weaker areas can lead to notable improvement. Engaging with supplementary resources and seeking feedback will further enhance your skills. With continued dedication, there's a solid foundation to build upon, and achieving a higher level of proficiency is within reach. Keep up the good work and aim for sustained progress in your English studies."; + } + + if (score > 0.2) { + return "Scoring between 21% and 40% on the English exam shows some understanding of key concepts, but there's still ample room for improvement. Strengthening foundational skills, such as grammar, vocabulary, and comprehension, is essential. Consistent effort and focused study can help bridge gaps in knowledge and elevate your performance. Consider seeking additional guidance or resources to refine your understanding of the material. With commitment and targeted improvements, you have the potential to make significant strides in your English proficiency."; + } + + return "Your performance on the English exam falls within the 0% to 20% range, indicating a need for improvement. There's room to enhance your grasp of fundamental concepts like grammar, vocabulary, and comprehension. Establishing a consistent study routine and seeking extra support can be beneficial. With dedication and targeted efforts, you have the potential to significantly boost your performance in upcoming assessments."; +}; + +const getPerformanceSummary = (module: Module, score: number) => { + if (module === "level") return getLevelSummary(score); + return getExamSummary(score); +}; + +const getListeningFeedback = () => + "Your listening skills are exceptional. You display a high level of attentiveness, accurately understanding spoken information across various contexts. Your ability to follow instructions and discern details from spoken content reflects a strong foundation in auditory comprehension. To further refine this skill, continue exposing yourself to diverse listening materials, including podcasts, interviews, and authentic conversations."; +const getReadingFeedback = () => + "Your reading skills are advanced, demonstrating a keen ability to comprehend and analyse written texts. You not only grasp the main ideas effectively but also excel in identifying supporting details and drawing inferences from context. Your enthusiasm for reading is evident, and I encourage you to explore more diverse and challenging materials to further expand your vocabulary and enhance your critical thinking skills."; +const getWritingFeedback = () => + "In the realm of writing, you showcase a commendable command of language. Your ability to construct well-organized and coherent sentences is notable. You exhibit a strong grasp of grammar and punctuation, contributing to the overall clarity of your written expression. Continue refining your writing style, and consider experimenting with different genres to unleash your creative potential."; +const getSpeakingFeedback = () => + "Your oral communication skills are a standout feature of your language proficiency. You articulate ideas with clarity and confidence, actively participating in discussions. Your ability to express yourself verbally is a valuable asset. To enhance your speaking skills even further, consider taking on leadership roles in group activities and engaging in more challenging speaking tasks, such as presentations and debates."; + +const getFeedback = (module: Module) => { + switch (module) { + case "listening": + return getListeningFeedback(); + case "reading": + return getReadingFeedback(); + case "writing": + return getWritingFeedback(); + case "speaking": + return getSpeakingFeedback(); + default: + return ""; + } +}; async function post(req: NextApiRequest, res: NextApiResponse) { - // debugger; - debugger; if (req.session.user) { const { id } = req.query as { id: string }; // const codeCheckerRef = await getDocs( @@ -86,9 +149,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) { const stats = docsSnap.docs.map((d) => d.data()); const results = stats.reduce((accm: ModuleScore[], { module, score }) => { - if (accm.find((e: ModuleScore) => e.module === module)) { + const fixedModuleStr = module[0].toUpperCase() + module.substring(1) + if (accm.find((e: ModuleScore) => e.module === fixedModuleStr)) { return accm.map((e: ModuleScore) => { - if (e.module === module) { + if (e.module === fixedModuleStr) { return { ...e, score: e.score + score.correct, @@ -103,17 +167,23 @@ async function post(req: NextApiRequest, res: NextApiResponse) { return [ ...accm, { - module, + module: fixedModuleStr, score: score.correct, total: score.total, + feedback: getFeedback(module), }, ]; - }, []); + }, []) as ModuleScore[]; const [stat] = stats as Stat[]; const fileName = `${Date.now().toString()}.pdf`; const fileRef = ref(storage, `exam_report/${fileName}`); + const overallScore = results.reduce((accm, { score }) => accm + score, 0); + const overallTotal = results.reduce((accm, { total }) => accm + total, 0); + const overallResult = overallScore / overallTotal; + const performanceSummary = getPerformanceSummary("level", overallResult); + // const logo = fs.readFileSync(path.resolve(__dirname, './logo_title.png')); const pdfStream = await ReactPDF.renderToStream( ); From 432f4a735ff1579a1cd82beb6ad1ce7905882ef1 Mon Sep 17 00:00:00 2001 From: Joao Ramos Date: Thu, 4 Jan 2024 19:49:15 +0000 Subject: [PATCH 06/29] Added QRCode for PDF --- package.json | 3 +- src/exams/pdf/index.tsx | 17 +++- src/pages/api/stats/[id]/export.tsx | 107 +++++++++++++------- yarn.lock | 148 +++++++++++++++++++++++++--- 4 files changed, 221 insertions(+), 54 deletions(-) diff --git a/package.json b/package.json index 0587b4e9..b8e872f3 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "@types/react-dom": "18.0.10", "axios": "^1.3.5", "bcrypt": "^5.1.1", - "blob-stream": "^0.1.3", "chart.js": "^4.2.1", "clsx": "^1.2.1", "countries-list": "^3.0.1", @@ -48,6 +47,7 @@ "nodemailer-express-handlebars": "^6.1.0", "primeicons": "^6.0.1", "primereact": "^9.2.3", + "qrcode": "^1.5.3", "random-words": "^2.0.0", "react": "18.2.0", "react-chartjs-2": "^5.2.0", @@ -82,6 +82,7 @@ "@types/lodash": "^4.14.191", "@types/nodemailer": "^6.4.11", "@types/nodemailer-express-handlebars": "^4.0.3", + "@types/qrcode": "^1.5.5", "@types/react-csv": "^1.1.10", "@types/react-datepicker": "^4.15.1", "@types/uuid": "^9.0.1", diff --git a/src/exams/pdf/index.tsx b/src/exams/pdf/index.tsx index 2a40147b..c0ca27b4 100644 --- a/src/exams/pdf/index.tsx +++ b/src/exams/pdf/index.tsx @@ -54,6 +54,10 @@ const styles = StyleSheet.create({ flexDirection: "row", justifyContent: "space-between", }, + alignRightRow: { + display: "flex", + flexDirection: "row-reverse", + } }); interface Props { @@ -65,6 +69,7 @@ interface Props { testDetails: ModuleScore[]; summary: string; logo: string; + qrcode: string; } const PDFReport = ({ @@ -76,6 +81,7 @@ const PDFReport = ({ testDetails, summary, logo, + qrcode, }: Props) => { const defaultTextStyle = [styles.textFont, { fontSize: 8 }]; const defaultSkillsTextStyle = [styles.textFont, { fontSize: 8 }]; @@ -89,10 +95,7 @@ const PDFReport = ({ ))} + + + diff --git a/src/pages/api/stats/[id]/export.tsx b/src/pages/api/stats/[id]/export.tsx index b4d8838c..91f3ab69 100644 --- a/src/pages/api/stats/[id]/export.tsx +++ b/src/pages/api/stats/[id]/export.tsx @@ -4,7 +4,6 @@ import { getFirestore, doc, getDoc, - deleteDoc, updateDoc, getDocs, query, @@ -18,21 +17,19 @@ import PDFReport from "@/exams/pdf"; import { ref, uploadBytes, - deleteObject, - getDownloadURL, } from "firebase/storage"; -import blobStream from "blob-stream"; import { Stat } from "@/interfaces/user"; import { User } from "@/interfaces/user"; import { Module } from "@/interfaces"; import { ModuleScore } from "@/interfaces/module.scores"; -import fs from 'fs'; -import path from 'path'; +import qrcode from 'qrcode'; + const db = getFirestore(app); export default withIronSessionApiRoute(handler, sessionOptions); async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "GET") return get(req, res); if (req.method === "POST") return post(req, res); } @@ -119,6 +116,17 @@ const getFeedback = (module: Module) => { return ""; } }; + +const generateQRCode = async (link: string) => { + try { + const qrCodeDataURL = await qrcode.toDataURL(link); + return qrCodeDataURL; + } catch (error) { + console.error('Error generating QR code:', error); + return null; + } +}; + async function post(req: NextApiRequest, res: NextApiResponse) { if (req.session.user) { const { id } = req.query as { id: string }; @@ -183,41 +191,68 @@ async function post(req: NextApiRequest, res: NextApiResponse) { const overallTotal = results.reduce((accm, { total }) => accm + total, 0); const overallResult = overallScore / overallTotal; const performanceSummary = getPerformanceSummary("level", overallResult); - // const logo = fs.readFileSync(path.resolve(__dirname, './logo_title.png')); - const pdfStream = await ReactPDF.renderToStream( - - ); - const pdfBuffer = await streamToBuffer(pdfStream); - const snapshot = await uploadBytes(fileRef, pdfBuffer, { - contentType: "application/pdf", - }); - docsSnap.docs.forEach(async (doc) => { - await updateDoc(doc.ref, { - pdf: snapshot.ref.fullPath, + const qrcode = await generateQRCode((req.headers.origin || '') + req.url); + + if(qrcode) { + const pdfStream = await ReactPDF.renderToStream( + + ); + + const pdfBuffer = await streamToBuffer(pdfStream); + const snapshot = await uploadBytes(fileRef, pdfBuffer, { + contentType: "application/pdf", }); - }); - res.status(200).end(snapshot.ref.fullPath); - return; + docsSnap.docs.forEach(async (doc) => { + await updateDoc(doc.ref, { + pdf: snapshot.ref.fullPath, + }); + }); + res.status(200).end(snapshot.ref.fullPath); + return; + } } } res.status(500).json({ ok: false }); return; } + +async function get(req: NextApiRequest, res: NextApiResponse) { + const { id } = req.query as { id: string }; + const docsSnap = await getDocs( + query(collection(db, "stats"), where("session", "==", id)) + ); + + if (docsSnap.empty) { + res.status(404).end(); + return; + } + + const stats = docsSnap.docs.map((d) => d.data()); + + const pdfUrl = stats.find((s) => s.pdf); + + if (pdfUrl) { + return res.end(pdfUrl); + } + + res.status(500).end(); +} diff --git a/yarn.lock b/yarn.lock index 507baf96..8cde7c8d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1437,6 +1437,13 @@ resolved "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz" integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w== +"@types/qrcode@^1.5.5": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@types/qrcode/-/qrcode-1.5.5.tgz#993ff7c6b584277eee7aac0a20861eab682f9dac" + integrity sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg== + dependencies: + "@types/node" "*" + "@types/qs@*": version "6.9.7" resolved "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz" @@ -1862,18 +1869,6 @@ binary-extensions@^2.0.0: resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== -blob-stream@^0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/blob-stream/-/blob-stream-0.1.3.tgz#98d668af6996e0f32ef666d06e215ccc7d77686c" - integrity sha512-xXwyhgVmPsFVFFvtM5P0syI17/oae+MIjLn5jGhuD86mmSJ61EWMWmbPrV/0+bdcH9jQ2CzIhmTQKNUJL7IPog== - dependencies: - blob "0.0.4" - -blob@0.0.4: - version "0.0.4" - resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.4.tgz#bcf13052ca54463f30f9fc7e95b9a47630a94921" - integrity sha512-YRc9zvVz4wNaxcXmiSgb9LAg7YYwqQ2xd0Sj6osfA7k/PKmIGVlnOYs3wOFdkRC9/JpQu8sGt/zHgJV7xzerfg== - bluebird@^3.7.2: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" @@ -1956,6 +1951,11 @@ camelcase-css@^2.0.1: resolved "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz" integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA== +camelcase@^5.0.0: + version "5.3.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" + integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== + caniuse-lite@^1.0.30001406, caniuse-lite@^1.0.30001449, caniuse-lite@^1.0.30001464: version "1.0.30001480" resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001480.tgz" @@ -2022,6 +2022,15 @@ client-only@0.0.1, client-only@^0.0.1: resolved "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz" integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== +cliui@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" + integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^6.2.0" + cliui@^7.0.2: version "7.0.4" resolved "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz" @@ -2240,6 +2249,11 @@ debug@^3.2.7: dependencies: ms "^2.1.1" +decamelize@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== + deep-equal@^2.0.5: version "2.2.0" resolved "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.0.tgz" @@ -2319,6 +2333,11 @@ didyoumean@^1.2.2: resolved "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz" integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw== +dijkstrajs@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz#4c8dbdea1f0f6478bff94d9c49c784d623e4fc23" + integrity sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA== + dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz" @@ -2405,6 +2424,11 @@ emoji-regex@^9.2.2: resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== +encode-utf8@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda" + integrity sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw== + end-of-stream@^1.4.1: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -2895,6 +2919,14 @@ find-root@^1.1.0: resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4" integrity sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng== +find-up@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + find-up@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz" @@ -3119,7 +3151,7 @@ gcp-metadata@^5.3.0: gaxios "^5.0.0" json-bigint "^1.0.0" -get-caller-file@^2.0.5: +get-caller-file@^2.0.1, get-caller-file@^2.0.5: version "2.0.5" resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== @@ -3991,6 +4023,13 @@ load-script@^1.0.0: resolved "https://registry.npmjs.org/load-script/-/load-script-1.0.0.tgz" integrity sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA== +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + dependencies: + p-locate "^4.1.0" + locate-path@^6.0.0: version "6.0.0" resolved "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz" @@ -4487,6 +4526,13 @@ optionator@^0.9.1: type-check "^0.4.0" word-wrap "^1.2.3" +p-limit@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" + integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== + dependencies: + p-try "^2.0.0" + p-limit@^3.0.1, p-limit@^3.0.2: version "3.1.0" resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz" @@ -4494,6 +4540,13 @@ p-limit@^3.0.1, p-limit@^3.0.2: dependencies: yocto-queue "^0.1.0" +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + dependencies: + p-limit "^2.2.0" + p-locate@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz" @@ -4501,6 +4554,11 @@ p-locate@^5.0.0: dependencies: p-limit "^3.0.2" +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + pako@^0.2.5: version "0.2.9" resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75" @@ -4591,6 +4649,11 @@ pirates@^4.0.1: resolved "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz" integrity sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ== +pngjs@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb" + integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw== + postcss-import@^14.1.0: version "14.1.0" resolved "https://registry.npmjs.org/postcss-import/-/postcss-import-14.1.0.tgz" @@ -4804,6 +4867,16 @@ pvutils@^1.1.3: resolved "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz" integrity sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ== +qrcode@^1.5.3: + version "1.5.3" + resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.3.tgz#03afa80912c0dccf12bc93f615a535aad1066170" + integrity sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg== + dependencies: + dijkstrajs "^1.0.1" + encode-utf8 "^1.0.3" + pngjs "^5.0.0" + yargs "^15.3.1" + qs@^6.11.0: version "6.11.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.2.tgz#64bea51f12c1f5da1bc01496f48ffcff7c69d7d9" @@ -5051,6 +5124,11 @@ require-directory@^2.1.1: resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz" integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== +require-main-filename@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" + integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== + requizzle@^0.2.3: version "0.2.4" resolved "https://registry.yarnpkg.com/requizzle/-/requizzle-0.2.4.tgz#319eb658b28c370f0c20f968fa8ceab98c13d27c" @@ -5847,6 +5925,11 @@ which-collection@^1.0.1: is-weakmap "^2.0.1" is-weakset "^2.0.1" +which-module@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.1.tgz#776b1fe35d90aebe99e8ac15eb24093389a4a409" + integrity sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ== + which-typed-array@^1.1.9: version "1.1.9" resolved "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.9.tgz" @@ -5904,6 +5987,15 @@ wordwrap@^1.0.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" + integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" @@ -5932,6 +6024,11 @@ xmlcreate@^2.0.4: resolved "https://registry.yarnpkg.com/xmlcreate/-/xmlcreate-2.0.4.tgz#0c5ab0f99cdd02a81065fa9cd8f8ae87624889be" integrity sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg== +y18n@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" + integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ== + y18n@^5.0.5: version "5.0.8" resolved "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz" @@ -5952,11 +6049,36 @@ yaml@^1.10.0, yaml@^1.10.2: resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz" integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== +yargs-parser@^18.1.2: + version "18.1.3" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" + integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + yargs-parser@^20.2.2: version "20.2.9" resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz" integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== +yargs@^15.3.1: + version "15.4.1" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" + integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== + dependencies: + cliui "^6.0.0" + decamelize "^1.2.0" + find-up "^4.1.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^4.2.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^18.1.2" + yargs@^16.2.0: version "16.2.0" resolved "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz" From 7a297a6f6c1fa0956c219308d900ac0fe1890149 Mon Sep 17 00:00:00 2001 From: Joao Ramos Date: Thu, 4 Jan 2024 22:20:00 +0000 Subject: [PATCH 07/29] Added level report --- src/exams/pdf/details/level.exam.tsx | 165 ++++++++++++++++++++++++ src/exams/pdf/details/radial.result.tsx | 36 ++++++ src/exams/pdf/details/skill.exam.tsx | 25 ++++ src/exams/pdf/index.tsx | 107 +++------------ src/exams/pdf/styles.ts | 46 +++++++ src/pages/api/stats/[id]/export.tsx | 41 +++--- 6 files changed, 310 insertions(+), 110 deletions(-) create mode 100644 src/exams/pdf/details/level.exam.tsx create mode 100644 src/exams/pdf/details/radial.result.tsx create mode 100644 src/exams/pdf/details/skill.exam.tsx create mode 100644 src/exams/pdf/styles.ts diff --git a/src/exams/pdf/details/level.exam.tsx b/src/exams/pdf/details/level.exam.tsx new file mode 100644 index 00000000..57ccac20 --- /dev/null +++ b/src/exams/pdf/details/level.exam.tsx @@ -0,0 +1,165 @@ +import React from "react"; +import { View, Text } from "@react-pdf/renderer"; +import { ModuleScore } from "@/interfaces/module.scores"; +import { styles } from "../styles"; +import { RadialResult } from "./radial.result"; +interface Props { + detail: ModuleScore; +} + +const thresholds = [ + { + level: "Low A1", + label: "Begginner", + minValue: 0, + maxValue: 3, + }, + { + level: "High A1/Low A2", + label: "Elementary", + minValue: 4, + maxValue: 7, + }, + { + level: "High A2/Low B1", + label: "Pre-Intermediate", + minValue: 8, + maxValue: 12, + }, + { + level: "High B2/Low C1", + label: "Upper-Intermediate", + minValue: 16, + maxValue: 21, + }, + { + level: "C1", + label: "Advanced", + minValue: 22, + maxValue: 25, + }, +]; + +export const LevelExamDetails = ({ detail }: Props) => { + const updatedThresholds = thresholds.map((t) => ({ + ...t, + match: detail.score >= t.minValue && detail.score <= t.maxValue, + })); + + const getBackgroundColor = (match: boolean, base: boolean) => { + if (match) return "#c2bfdd"; + return base ? "#553b25" : "#ea7c7b"; + }; + + const getTextColor = (match: boolean, base: boolean) => { + if (match) return "#9e7936"; + return base ? "white" : "#553b25"; + }; + return ( + + + + + + Level as per CEFR Levels + + + + {updatedThresholds.map( + ({ level, label, minValue, maxValue, match }, index, arr) => ( + + + + {level} + + + + + {label} + + + + + {minValue}-{maxValue} + + + + ) + )} + + + + ); +}; diff --git a/src/exams/pdf/details/radial.result.tsx b/src/exams/pdf/details/radial.result.tsx new file mode 100644 index 00000000..df999000 --- /dev/null +++ b/src/exams/pdf/details/radial.result.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { + Document, + Page, + View, + Text, + StyleSheet, + Image, +} from "@react-pdf/renderer"; +import { styles } from "../styles"; +import { ModuleScore } from "@/interfaces/module.scores"; + +export const RadialResult = ({ module, score, total }: ModuleScore) => ( + + + {module} + + {score} + Out of {total} + +); diff --git a/src/exams/pdf/details/skill.exam.tsx b/src/exams/pdf/details/skill.exam.tsx new file mode 100644 index 00000000..a23e7c69 --- /dev/null +++ b/src/exams/pdf/details/skill.exam.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { + Document, + Page, + View, + Text, + StyleSheet, + Image, +} from "@react-pdf/renderer"; +import { ModuleScore } from "@/interfaces/module.scores"; +import { styles } from "../styles"; +import { RadialResult } from "./radial.result"; +interface Props { + testDetails: ModuleScore[]; +} + +export const SkillExamDetails = ({ testDetails }: Props) => ( + + {testDetails.map((detail) => { + const { module } = detail; + + return ; + })} + +); diff --git a/src/exams/pdf/index.tsx b/src/exams/pdf/index.tsx index c0ca27b4..dd9d5b98 100644 --- a/src/exams/pdf/index.tsx +++ b/src/exams/pdf/index.tsx @@ -1,64 +1,13 @@ /* eslint-disable jsx-a11y/alt-text */ import React from "react"; -import { - Document, - Page, - View, - Text, - StyleSheet, - Image, -} from "@react-pdf/renderer"; +import { Document, Page, View, Text, Image } from "@react-pdf/renderer"; import ProgressBar from "./progress.bar"; // import RadialProgress from "./radial.progress"; // import RadialProgressSvg from "./radial.progress.svg"; import { Module } from "@/interfaces"; import { ModuleScore } from "@/interfaces/module.scores"; // import logo from './logo_title.png'; - -const styles = StyleSheet.create({ - body: { - paddingTop: 10, - paddingBottom: 20, - paddingHorizontal: 35, - }, - titleView: { - display: "flex", - // flex: 1, - alignItems: "center", - }, - title: { - textTransform: "uppercase", - }, - textPadding: { - margin: "8px", - }, - separator: { - width: "100%", - borderBottom: "1px solid #89b0c2", - }, - textFont: { - fontFamily: "Helvetica", - }, - textBold: { - fontFamily: "Helvetica-Bold", - fontWeight: "bold", - }, - textColor: { - color: "#4e4969", - }, - textUnderline: { - textDecoration: "underline", - }, - spacedRow: { - display: "flex", - flexDirection: "row", - justifyContent: "space-between", - }, - alignRightRow: { - display: "flex", - flexDirection: "row-reverse", - } -}); +import { styles } from "./styles"; interface Props { date: string; @@ -70,6 +19,7 @@ interface Props { summary: string; logo: string; qrcode: string; + renderDetails: React.ReactNode; } const PDFReport = ({ @@ -82,6 +32,7 @@ const PDFReport = ({ summary, logo, qrcode, + renderDetails, }: Props) => { const defaultTextStyle = [styles.textFont, { fontSize: 8 }]; const defaultSkillsTextStyle = [styles.textFont, { fontSize: 8 }]; @@ -94,14 +45,8 @@ const PDFReport = ({ return ( - - + + Email: {email} Gender: {gender} - + Test Details: - - {testDetails.map(({ module, score, total }) => ( - - - {module} - - {score} - Out of {total} - - ))} - + {renderDetails} - + diff --git a/src/exams/pdf/styles.ts b/src/exams/pdf/styles.ts new file mode 100644 index 00000000..1e31de4e --- /dev/null +++ b/src/exams/pdf/styles.ts @@ -0,0 +1,46 @@ +import { StyleSheet } from "@react-pdf/renderer"; + +export const styles = StyleSheet.create({ + body: { + paddingTop: 10, + paddingBottom: 20, + paddingHorizontal: 35, + }, + titleView: { + display: "flex", + // flex: 1, + alignItems: "center", + }, + title: { + textTransform: "uppercase", + }, + textPadding: { + margin: "8px", + }, + separator: { + width: "100%", + borderBottom: "1px solid #89b0c2", + }, + textFont: { + fontFamily: "Helvetica", + }, + textBold: { + fontFamily: "Helvetica-Bold", + fontWeight: "bold", + }, + textColor: { + color: "#4e4969", + }, + textUnderline: { + textDecoration: "underline", + }, + spacedRow: { + display: "flex", + flexDirection: "row", + justifyContent: "space-between", + }, + alignRightRow: { + display: "flex", + flexDirection: "row-reverse", + }, +}); diff --git a/src/pages/api/stats/[id]/export.tsx b/src/pages/api/stats/[id]/export.tsx index 91f3ab69..4a8829b9 100644 --- a/src/pages/api/stats/[id]/export.tsx +++ b/src/pages/api/stats/[id]/export.tsx @@ -14,16 +14,14 @@ import { withIronSessionApiRoute } from "iron-session/next"; import { sessionOptions } from "@/lib/session"; import ReactPDF from "@react-pdf/renderer"; import PDFReport from "@/exams/pdf"; -import { - ref, - uploadBytes, -} from "firebase/storage"; +import { ref, uploadBytes } from "firebase/storage"; import { Stat } from "@/interfaces/user"; import { User } from "@/interfaces/user"; import { Module } from "@/interfaces"; import { ModuleScore } from "@/interfaces/module.scores"; -import qrcode from 'qrcode'; - +import qrcode from "qrcode"; +import { SkillExamDetails } from "@/exams/pdf/details/skill.exam"; +import { LevelExamDetails } from "@/exams/pdf/details/level.exam"; const db = getFirestore(app); export default withIronSessionApiRoute(handler, sessionOptions); @@ -122,7 +120,7 @@ const generateQRCode = async (link: string) => { const qrCodeDataURL = await qrcode.toDataURL(link); return qrCodeDataURL; } catch (error) { - console.error('Error generating QR code:', error); + console.error("Error generating QR code:", error); return null; } }; @@ -157,7 +155,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) { const stats = docsSnap.docs.map((d) => d.data()); const results = stats.reduce((accm: ModuleScore[], { module, score }) => { - const fixedModuleStr = module[0].toUpperCase() + module.substring(1) + const fixedModuleStr = module[0].toUpperCase() + module.substring(1); if (accm.find((e: ModuleScore) => e.module === fixedModuleStr)) { return accm.map((e: ModuleScore) => { if (e.module === fixedModuleStr) { @@ -192,9 +190,22 @@ async function post(req: NextApiRequest, res: NextApiResponse) { const overallResult = overallScore / overallTotal; const performanceSummary = getPerformanceSummary("level", overallResult); - const qrcode = await generateQRCode((req.headers.origin || '') + req.url); + const qrcode = await generateQRCode((req.headers.origin || "") + req.url); - if(qrcode) { + const overallDetail = { + module: "Overall", + score: overallScore, + total: overallTotal, + } as ModuleScore; + const testDetails = [overallDetail, ...results]; + const renderDetails = () => { + if (stats[0].module === "level") { + return ; + } + + return ; + }; + if (qrcode) { const pdfStream = await ReactPDF.renderToStream( From 5e8e46ff096ad8c0b4a13118f5c923cee9e716a0 Mon Sep 17 00:00:00 2001 From: Joao Ramos Date: Sun, 7 Jan 2024 23:51:05 +0000 Subject: [PATCH 08/29] Added PNGs with partial radial progress --- public/radial_progress/azul_0.png | Bin 0 -> 2792 bytes public/radial_progress/azul_10.png | Bin 0 -> 3127 bytes public/radial_progress/azul_100.png | Bin 0 -> 2892 bytes public/radial_progress/azul_20.png | Bin 0 -> 3283 bytes public/radial_progress/azul_30.png | Bin 0 -> 3393 bytes public/radial_progress/azul_40.png | Bin 0 -> 3538 bytes public/radial_progress/azul_50.png | Bin 0 -> 3440 bytes public/radial_progress/azul_60.png | Bin 0 -> 3585 bytes public/radial_progress/azul_70.png | Bin 0 -> 4100 bytes public/radial_progress/azul_80.png | Bin 0 -> 3963 bytes public/radial_progress/azul_90.png | Bin 0 -> 4473 bytes public/radial_progress/laranja_0.png | Bin 0 -> 2972 bytes public/radial_progress/laranja_10.png | Bin 0 -> 3248 bytes public/radial_progress/laranja_100.png | Bin 0 -> 2977 bytes public/radial_progress/laranja_20.png | Bin 0 -> 3414 bytes public/radial_progress/laranja_30.png | Bin 0 -> 3433 bytes public/radial_progress/laranja_40.png | Bin 0 -> 3513 bytes public/radial_progress/laranja_50.png | Bin 0 -> 3468 bytes public/radial_progress/laranja_60.png | Bin 0 -> 3652 bytes public/radial_progress/laranja_70.png | Bin 0 -> 4087 bytes public/radial_progress/laranja_80.png | Bin 0 -> 3877 bytes public/radial_progress/laranja_90.png | Bin 0 -> 4268 bytes src/exams/pdf/details/radial.result.tsx | 54 +++++++++++++++--------- src/interfaces/module.scores.ts | 1 + src/pages/api/stats/[id]/export.tsx | 20 ++++++++- 25 files changed, 53 insertions(+), 22 deletions(-) create mode 100644 public/radial_progress/azul_0.png create mode 100644 public/radial_progress/azul_10.png create mode 100644 public/radial_progress/azul_100.png create mode 100644 public/radial_progress/azul_20.png create mode 100644 public/radial_progress/azul_30.png create mode 100644 public/radial_progress/azul_40.png create mode 100644 public/radial_progress/azul_50.png create mode 100644 public/radial_progress/azul_60.png create mode 100644 public/radial_progress/azul_70.png create mode 100644 public/radial_progress/azul_80.png create mode 100644 public/radial_progress/azul_90.png create mode 100644 public/radial_progress/laranja_0.png create mode 100644 public/radial_progress/laranja_10.png create mode 100644 public/radial_progress/laranja_100.png create mode 100644 public/radial_progress/laranja_20.png create mode 100644 public/radial_progress/laranja_30.png create mode 100644 public/radial_progress/laranja_40.png create mode 100644 public/radial_progress/laranja_50.png create mode 100644 public/radial_progress/laranja_60.png create mode 100644 public/radial_progress/laranja_70.png create mode 100644 public/radial_progress/laranja_80.png create mode 100644 public/radial_progress/laranja_90.png diff --git a/public/radial_progress/azul_0.png b/public/radial_progress/azul_0.png new file mode 100644 index 0000000000000000000000000000000000000000..04fd04651acd50f5d4a9faf85afd25b5294010ae GIT binary patch literal 2792 zcmY*bc~BF17XEdPCJ9N10Wky+3{fB`0~!>xBApBfL2&{irRZXa$F`2T6tb>}D@nj` zO#qd#JlLWMh;e675WJW{lYpq`YE(wi#VJa;9hG6#0*$iGNIK3RU0vP3cYWXc-s>(@ ztc+nX_zVC5OCB4Qh~LxjOG$$#-=ug=-stJEYj*+Q%$>ZT`%m}V0AL)KM@f_RRH$qd z6OpY-icii0UPy?#fkYMw!~|b_ z5U}0=RPE~7RYOwrss+yhZUa9V*HKI+)0B}lRt@hXiAVZ|c1XzNaKS<*nimoh^7+(| zE!=1Ok1t@2X(8I4^}ubGGmpocGt#In{^~tMWHPK1BIK)}Xng2p0@ozNOM;uDVKSNg zM;(SNA+yR9G0T!2WGLybhF&Y29*Nwe57vPFxhb|7a~F3bn*5+tEYPSJIe8- z9vtITO?xL@SB}3;3d#aAi)H6z@a_TTeh}`n-?l$FJ*F&!K&vw7 z0awUSMB%hL|Jbwj9`_R63m>_~Xm_*T}_|}`ligZ|&9#-_IyX(G@_^YTjGvXa~fn3xJs!xcw z`tpA{o)h41g9kWeKFHDJ-29qoioIUJVlEa~tV$)>tR26a`ISWVSM4c0<|O|8*D zIOh`+mJ_bn^v6`u=GA2bUg+wfL}z3d)%dD9dAW%5dN7DUQ|{`n2qXo|8vC&lz)|-z z6n+%(>91M!fZxys+085-bStBnPtKT86`?eVHe`UNV{&}~n5|M=EA=LHZAtM;Xb2Mv z{eTDJH1zDW((TGk>S3132LvO)@4TyUtlM-UnGu4^V69KVDtR|Y`XAs8Y!mYsH8`UzeqlQ)(R{(h*6 zVYFsMdEX%>K|4S}Umrb0TNB>x2Pl{(M!wVOmx~1|y`6Yja7jSm>!pc<^|Pp0YbPd+ z&42_y655rOP~t?r)Ykg2C|b`;sw?<3QstOmQVgjnz)-_lQJAX}s@WZy+O(V0vTkC~ zw0)FwbBtBW=d|0@5JWzWN#Erd?qx#7W~h@xEuYKLeR{YKM>*?g)d@8>Di2^PzVi>{LT905Ny5FSq5k^rm)Bg(3l0-G@Rsw8 z^_6J>VBbwtcOzr^gt18`psk%;RN5Xp5$)Cz4_bd;;;k zO625&Y0`*i&;{3(uqWtCL1(Yf(*R^!R7%W`#glG*$U6VTX0zV%fp_?jo;1PZ$5Q;I z+V+BxMc)!V;Y%uZV=kvQ+Xsm+sm;Plx8kb*EJePgejnyC_pgrU=F0c#rs@xl2rX^e zX7!upa|urPuO3~t{;wl+^vo_|sTz7!etToulyL8c?L^P{EzgQGkjK+E5VRQ3pWK?L zWwFaY*UH&o`Z4Sw8yzXTvdhZ>JX`2XMyc%K-xSTaE9Sm92-BTjp literal 0 HcmV?d00001 diff --git a/public/radial_progress/azul_10.png b/public/radial_progress/azul_10.png new file mode 100644 index 0000000000000000000000000000000000000000..ffd21bdfd0347712715c86c90deb30a018d7057e GIT binary patch literal 3127 zcmY*bX;>2n6W+~5f(b_q7{X~9H9!qef<@$50vLi710v;;Ce$NZqzHUgD%B)_0v1I; ztV$o&1r)3(Dk##5Nl-481_Y%P`*{EYS_MB)DERO#g>2d%`|LG4^Ul08?`*a>SV%Ku z83F)669xK*qGu_3NXVqQCs9^&CrV;qWHJCOqR$;*=i~h|=*6(e-#2V;aoo)8tWTqa zV0*xo>Fg}OjKky8@m<;PhJw4l|F}8vCQ>-`P4J#;C3L}z@m}}eY)5w|TfYjLIPJ{! zYE!9D=fLbNi+>EDjRP|ImxNV3`AT{sDDLM5<@~`|-2Z#W3DXEsw5=(H$z(2AYXjw^ zG#r0)pw%V4Fc2bn7F*3uY#MmonnS6w(UPSS$-E)5kCQfY-wvQn;*#(-7I~JG7J#ud zxarin+ZmJVPCfHHDvr0amn#(D-zbo14uro2k&Lhf%4#_ZWarIZP#y=E{R+5O=2hsqKMue1pPVvEi%4@y?Y4r6&@s_fNcjF0v0% zkJiiHAL3swz(R>QkYgXEtR{)%*pt%#%Z-d`)lK7)TmR`O!)?dlf4h5p>{IuFM;p7v zp7QWJrmG~|;jxHyA#FwHZzy%H;*cU=_{!|_uMgtKzX!fe_gj+CLh3zfl{uI)0$})% zAP!_bA#rE;OhQ|Wozjals`+?)ih9+ARdpOFM&H9KC;@G;rKl45zm zF~P)u+o0?~R*Fi;Is2zWw_d+dXUd^|h58pYJ|wn;n?mkh$cI zw?oX&vxTqZ3Q%midjKsZ)VF(P z< z#UH=lL`gb#;bd}uXVu=AV;x1NZ6>m>1sBJ%XD;}M_fVb(Bb3l#GO>HQpMVD z4ZJ;ofTs3>69o-vvubj|qH5P|vR|pCT{1r;31w=Y9`-6abSJ~7{GtG7;G1kXbKyzO z9+mHd&L7{tNbv62Kho>9-rm&rAnnJg$tb_uII+>^c0Z`FxnVOY+9I)!ncw-mlH*cw z`}?yKPM1XE*Bl>(77m`tKV~@ODB?WEi6~8%&D{X*sszKDI`YxOnSrYbs zcw_55Xn%O3NNJ5t2D>XV8Y(B(k?)^M_rO6wSlZ}SrRCB^m0S98zgVxvnnz3VLHG{T}#yaF{c!^Gu=WxhE_4bnuujRgDZenE@#(T=rl->O^_YV%CCRI z_HlgIV`BuIs#_j9WY#5(#8MDcE`}FR6!O<)+c;UAmm97Wc0JV#hVlhL_U4Cta)f)| zM*sm<2t^47-Pg0?a&>bp;2q)Gzwr(os$NQj8@piNz$B1;K@Z>vA;_Vh*jVl6PZobd z9yYdZ!zKbJ#MN+-GMcCC8@1LDG8o`SW*fBXtE3ztTv=XmM)I6|kLV1EOsSM)3^lTr zBi;&jnGp9Ls-OUYKVUP0@af)kmZNPu&>Pwtcy`Hvx*O0D5~mC|>A#jLj3?NEK^hgR zs0)%Br*vr{G$!m-QXvr&T@Si}W91SI!I}_3f@3Rciy0?8TyBtNrLJP9KnZaSG#|+ddSY9h+? z!p>6G9%-p6_MOmV<&tG9a{E3!Hdeb9U@=g_nHAL~QZ7Dd#^~Q2GI{h#I9()E!Qej{ z1noM0bc~f>@YAm*uFF|fH{dF?y|z*8Y>^f)|jiZW>roPi)OhPBi)Po85c z-&g9T3&c(#*_3}EF_}rLmFSzix!x#%Vc6=&^3T5a#ljP|ZAX_qaow}v!zGHQjdvMFoy)2}d{V0jgrcbI_7j->5m zt?PPPgP~-NHS_%poxTx6 zjkK|&i9~o~1LS(J>t!=0SV-%{Xyb5bJQwIrg)j3)N>Z@)MOwYLiGvh_IU1>WM|yh` ziFUhVutLA^5K%Bh(yDLa95z#+4M2Szq8R+>rarq=j!I>cL#-!z&k5v$L_~8`0RI!5 z%v~um0?bar4~=A_c@4={`p@=p?C>(8(a0-t{k`IkUmhb-3|`AZuzIqz=#R0M3iPi> X7|Z^*ad+Ba(;PNYK(PNsfkgR#%W1do literal 0 HcmV?d00001 diff --git a/public/radial_progress/azul_100.png b/public/radial_progress/azul_100.png new file mode 100644 index 0000000000000000000000000000000000000000..99a924df89021a2578f84143cba3705e60984f13 GIT binary patch literal 2892 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 F`7a7+7XttQ literal 0 HcmV?d00001 diff --git a/public/radial_progress/azul_30.png b/public/radial_progress/azul_30.png new file mode 100644 index 0000000000000000000000000000000000000000..7bdbeba9c065653ecb377835aedd7340758eb3a0 GIT binary patch literal 3393 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 literal 0 HcmV?d00001 diff --git a/public/radial_progress/azul_40.png b/public/radial_progress/azul_40.png new file mode 100644 index 0000000000000000000000000000000000000000..9cbc692ff4eeb23800f56b7e89a874978eb473e1 GIT binary patch literal 3538 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 N8L%?g?>8U5`Nt8pe&3UDWPV&u+iu_bc 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 H%;5YFTXwR1 literal 0 HcmV?d00001 diff --git a/public/radial_progress/azul_70.png b/public/radial_progress/azul_70.png new file mode 100644 index 0000000000000000000000000000000000000000..6351d5b8ef7441087a2da1b04e4b2659ef07b9f5 GIT binary patch literal 4100 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 literal 0 HcmV?d00001 diff --git a/public/radial_progress/azul_80.png b/public/radial_progress/azul_80.png new file mode 100644 index 0000000000000000000000000000000000000000..714a10a1d3341840eae397104435a51fd6c8c86e GIT binary patch literal 3963 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 F{vU&Vd{Y1b literal 0 HcmV?d00001 diff --git a/public/radial_progress/azul_90.png b/public/radial_progress/azul_90.png new file mode 100644 index 0000000000000000000000000000000000000000..93febb166a7e15f1bc468da53d6659bd7de9605c GIT binary patch literal 4473 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 ISx-Uszg5%>?*IS* literal 0 HcmV?d00001 diff --git a/public/radial_progress/laranja_100.png b/public/radial_progress/laranja_100.png new file mode 100644 index 0000000000000000000000000000000000000000..f360e144149ad12c2370cf9afadf9e735616003e GIT binary patch literal 2977 zcmZ8j2~-nz8lOqRkdOliS4cp@5HwsGB&cAKNw@-D;qXG&4H0XM*oO;N>w!6t%dLWf zSVT7jR8a9mMXVYFbW>I|TC@ehtB8p0R*-Aanw>1k;M=@6ndJL^|L^{OGlk+Maa22| z9RL8-cu{l`dQUT7L;`BrSzl86xl2W z_RJvr^LRW3kH|o8M9G#>JQq@u?_U2SlCtA`Fpr1Y0Zb-lY00~7mo#|vLsa8f&hI9i z8QUC~!EOu&gT{M(%l3iV7CmnPJDb65?gfEznog&aEM50@feKbDm1%gQ{r0cYBoYhm zf=J;yOM}dkNF>Ex4snPsR+b*faCqX*U`!&kJ2}Wcrdapkr$0Xltrv2oJ@<``hBgZyIYl`pwa7& zPB&R5*q^TH{0~^>`N1Kaf|SAeYk(0JcZK*y*|n0ev54C7=BeJpR|}hzZH@r*7@yHk z1VFKTJvtr(ZyCa5-W7%E*^erXq4hKP5hu;DVEGBU_#1bgLyY}J1@y_rcTV?vI@SXH zQ76q!fjo>@+U5*p5flJ_1wn`}P6GTR-p%Dt6)43caZ^L4kDtpU1TPCF$qU3j@Na{z z3#SaN@<_ZG)>Mp!9MdzQ#2F6)rCEOU_JR_UzYWjH+gd_>g_9;I5AxrhV~7Qa=tbMKB9%{atI#U$B<%*Do0 zf7pp$D~2FSS;CQ(K>b;kAAkBZpU?EJq|!c>{!Zi5LrkD+*NKLx{EBKUNns(O-=CB) zpi(!iVa^r-CtB%pc!PDEh4)D**CdUgZ04z&l`rGID_m#QsN!%9!jtJ|cfZIC%DVuF zzdBWOC71CFrkCn{1&}88wp0Yjs?~Ip3L0Y{U4yyRF6RW+j3x950A!u8^ffyFmaP=- zCnOYU9S@NuKn07tF&CLg#EOJNeJ+chId2{=3#5o=yO2p)*g4fJLV_!g`U|aG2^m{# zncmZF6{+zMde*`dsu-Ma6a~Lv{v4fxZepS3vv3)zxXzkKqg{JJnhp3KO8M-JR3CaN zj>G7IWTFgVWCa%{*LbpbGjuWAE^N z%%*t@ZX-YDoXsmqnv1-}CFE^(c)JaIxCy=HYty&PdP@DN*+#svIs&f5RC;tPGj;}Rq|n8z<(xQk){XMBl++d6e#*$`c$jDHgMLow;*HA>dF|in zsv)298*!7l%$NV;@0hxPAgjMwEd1BUs{FZBGSJ@HG4D@G$RaFoeP~_Ox4_v`!@b=C z0^*3g={&tM%S&)_QyWJZ_E;&u3rqiq!wRO~*6$p;RKfW`{8fod zxkbi}U0;fgo<8iKl+AvL0WRvlwG@ZF$1y>TR?Tz-F}N~X?>a?n-`+c2RqCt#Nck3V01jz&_>|*@!A5f>sU8m& zmA}1Q0E$h@qJ{F~GwoCT?Zcg1Zkw=#;a6ufZI*f;8S+btN( zKOAD^0(hijd#dLX`-D*TWr3sceWjpTmk+*Nt;TYLL+O%wD0QtJI8dMb`@abcq$x`I zFij=|vOjtZ1_>~FfM3(eJE93h_Fp^6P0uTOUA#qO;c8yQ;mrqgXn|PtW8rQ)VhG{D z;&5eAfH+qYa8+`xT+O?Ap5^lgdo5;7+bgYB78Tqf8X`&GS-TAHTsEfxw^cKA3?JpV zsHUHjK82T^q9sL(!-peMz*?8+PMqzOs;}p0Lw_7)Ir2CtX(jfM(T)Z+(D;oL>o0)D zwy*)usCJBxCU)O#9uJpvt=T^%d)3|x8V8TO$P5YMj;Lez)a|LYvnHi|f zDn@@U#zscFk)s1Ve~{Nw(boh29kNK40rRjBRq=xgMh!_y(n1Uw3|@XLkW{B zgrW|xI_`r#AL30BJy<`O#UcbUc7v>*nhm*%lc{OfdT8A(bZaQgcc@wvmfJPPRaca2 z_PdO)eJ$9kFw+srF6ditN{NDs%y=p&!5=Np8{7hlL+H6_)%Et!$f~E96FhKo#mg=G SCJr0TloTJcB>J3CqWUjP^h1pR literal 0 HcmV?d00001 diff --git a/public/radial_progress/laranja_20.png b/public/radial_progress/laranja_20.png new file mode 100644 index 0000000000000000000000000000000000000000..9765ae01baf6e405326421d48436d3cdde1f8af8 GIT binary patch literal 3414 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 J-}z$Me*xxwSPuXI literal 0 HcmV?d00001 diff --git a/public/radial_progress/laranja_30.png b/public/radial_progress/laranja_30.png new file mode 100644 index 0000000000000000000000000000000000000000..62a909861e2a9f4ab862d5d7500e4225bcda0f29 GIT binary patch literal 3433 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 Ef6kGiGynhq literal 0 HcmV?d00001 diff --git a/public/radial_progress/laranja_50.png b/public/radial_progress/laranja_50.png new file mode 100644 index 0000000000000000000000000000000000000000..9f05d69b93532eb0daafa8e181bbedc6520ebe10 GIT binary patch literal 3468 zcmYLMdpuP68$NT#j2VWQCb!0vajB4vk)Iq&!VKF{+$@B2NI%=h4_sxVam08rh! z#l;)B@14Jth{$IX@#!_>rX0RyR|Ei%>GK!n`RiCY66~)UpsUNTi{;-qQw(T&Rh91gdzq-=|9=8Fh}!)?Y`jZQZI zka%+omzX1sn_!tec@Dg<&KX!37;>%VVPr#A5MEG2ddd8E8Y#z!Y`TpB)St z`_WQtOQd5M48~&YZZ;QJD}LFVpazDqVZh!nUI7z|o=*e#w50#>-L6UaN;*!THP9mFk46;44uqFbOn6xYlrG$zG2hWt>vtL z^H1d5)4NHLCI2>~ki$F>en8QSN^!2(^K_1gQj}}D1k&F_bbf}Gux;oO0j}z89{U&< z*_61Wk&EsFxg%g4rj36Yp&5@(8XLWzv2Q8%wzDudU!-Y6kFB)>N@70Hin;kj*-U!o z*l=5l<8J2xeu$1jWr#xu)zv(=*Q)*iN5viba#i&W>2ajB;xVf_SPxbLe3QTjBnrcG zyZ2tXEMvvm>3)JE=iKvuy+D=AtAw>G&CKNrby=qLK?Vi{NClQ^4`GvGAnZGxF#9RV zSRfm3%zgKP7ua;~-q}HQi@Bdmspa^Cgdg}xBvfJ6M*5%{1{9IT`POAAo8TRG1weQ9 z+2DN^qRf3b||V5xz>uXKfz_^HV-*K5+90^A8> zynWp!h>D~AZkgoXQ(JzVl=^pyJCyd5K(L?@%a#t#h-R$9*K2_P$VtabcY z36E&{_uO2{+Dw;LUvq_|EE78To~vEdO}nq88k1cZ;|%1i&%I)t7iK}ow#mBvlHFOc z56yiy`GM$(di!IOVSzb=1>m`^kKzctb93JNT>Rwtx(|gpl#!87tBOvcBra;qS1cP> zRG9UGTg(!j-AS#%*!bDm26&WqXx2@kbfqhl+k*!k&S9}WavMaa7%(f^3J0FoeE+rL zYhCdrZ=giq=TFVUTHBVC_0d@aW_fBmr(Si^#~1)5e5d}6MFEAK=KNDrG9|0>G+mUZ zbh&c-GPRcsb%HAwyX>z#Q=lLq%oYE>Vh7QN7l@WxdO@SeqQIJM2oQ^y>bGwF1)J0< z+%07)w|guvh>&V09>K%FD^~A}J!X`zVvCHM1?{EhG?<)nwdX3VXv)uJ*l?FEOe2ZO zF86MwVFrXlZ7xm1oAgUWdEUwzWcb{joL`Mx69g@)C@<-G4NfZ|J-$kxs|*|+;zsR5Ecfuf8~drI7hBOh zZOWRZr>;8(TIK_x(d>Vyv$N+3Z=zAxIiJG2XP+gLf*k|~n5h4ifb_{V@W>&{Ix9un zAxQeL57w^IH|wbt_4*%qEtS2?TYeE$7t6y6>xosa&l=x#u~*^Yh~~b#XB`~-3YWkckcy>Kp?z>gHegjIiLKJlkmQZ~& z$Zm!hpLO9%-Ry0Kp&jXp9kB?aj_$O_JJpFjYV7Jg@(RQ23tI+5po4(%|8a~?S@<7D zyVog{fI@zeJGq%B?}3ld>dvdEdxWI_ALLl zGz5`fcfPoQBQ6crW`_YUE*7;RGyQ~^h`I@4)7?ykVe{wIA};4QCwMG_>NpFtx~o?| zg+!++xXg!QEDeqltTjF!U&7Gw$S4CVII}(kvmrNrF$a`iLkrBaP??RPu{TjrD8W!! zjpM=J`YoH4d(Ib$5A0VEKxROwX>!(?L!a|_R?;RK^L zl0H^$7hS77__w~tv=%%Lzc^FPu}@O@xNkE`N*X=*a^wO@kN+;}5*}E-*jHBREE_@2fCEQa)5-0mNg2|SweZ2IP=)3=Bpz>o?#AJ zP?ps*&(eX_9G(utB!brCe$ibW$hF5^NfeXOHl9C4N^kd#1n>x;&hLB6tb^)E9TI=1 zmq`GBZAQiChH4=)OcoE=@8MnKb}iZ+?#dhY>%pPG_zR^mF_q%yv4Ew)5IY*>#fp6G z1rWZfc5)p#!K7_}0EM%p=@I^VEH_?wB4wpHYAII#&4YXwe&re^5}?Z5kk(mIzJz}K zWfb4_wP*75D8LU9d88Lk4rpW(95ge8F^&rA%(Mw3QGy*el0P?l0vVsn-*#8(`Ait0 z{FtAtlSM524 zaGEA$#KqEysdCxl&8Gn#DdcT&Jq#~212ouFR~KHbS#wCIfyhao9~O(<2ZdH^3OSN2 z<7X>rvn~G~0@+w(3&ne9)KjJvOC@o85uFUT@;_9W#jfD^Yaz#vBJYzXr_d4Wj3>Ge z>d0KbC-GAl_FYLCb}4<=@b;aD6II&_zSK|OCwP@=gExYbJ}0T;QRhKJTK;^EEZ^=r zaY}q_JV}(I|MLWOAn96`PmRuHMKz&jhkb)ytts`Z{Y2i5gYH`NVGlzlDjc-yIJ4{a z5z(oQ+rd}5Ggbufzymg=B=i^$6(5Q&B>MXu6zx%NH)peOK4b0-v;3WnsoG0PhQ>!5 zfG{4>chdohyZ$V>xDE8Z9lvZT;5hQ~{q2)I{{&y?X5E+xl!QC&zx<`S54x3hjg0yP znr4tG+zSDc2^a21RmSz1WnaNwH~ypwuCRxCtne5G)sI<*{6Q!?hGXNe{EF+RtEo!Q zdft$j{P^3m{gfOjiP1T!P4P*2q=Ht2S=O7R38XXH@szX#45_oVuk1>O0B)Gl69!;# khF>GXB1=N&E4Wa19B`AzXgTXh?wEzJD9ESN`X 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 literal 0 HcmV?d00001 diff --git a/public/radial_progress/laranja_70.png b/public/radial_progress/laranja_70.png new file mode 100644 index 0000000000000000000000000000000000000000..9db27bd023cd66dbdae955124d09523cea844d3e GIT binary patch literal 4087 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 literal 0 HcmV?d00001 diff --git a/public/radial_progress/laranja_80.png b/public/radial_progress/laranja_80.png new file mode 100644 index 0000000000000000000000000000000000000000..0b8529b52510b14535da235f4afd8934c31fc917 GIT binary patch literal 3877 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 literal 0 HcmV?d00001 diff --git a/public/radial_progress/laranja_90.png b/public/radial_progress/laranja_90.png new file mode 100644 index 0000000000000000000000000000000000000000..7110916b9db2135ea36229025b8ca4cb73e63aac GIT binary patch literal 4268 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 C!CLwN literal 0 HcmV?d00001 diff --git a/src/exams/pdf/details/radial.result.tsx b/src/exams/pdf/details/radial.result.tsx index df999000..00a26888 100644 --- a/src/exams/pdf/details/radial.result.tsx +++ b/src/exams/pdf/details/radial.result.tsx @@ -1,36 +1,50 @@ +/* eslint-disable jsx-a11y/alt-text */ import React from "react"; import { - Document, - Page, View, Text, - StyleSheet, Image, } from "@react-pdf/renderer"; import { styles } from "../styles"; import { ModuleScore } from "@/interfaces/module.scores"; -export const RadialResult = ({ module, score, total }: ModuleScore) => ( +export const RadialResult = ({ module, score, total, png }: ModuleScore) => ( - + {module} - {score} - Out of {total} + + + {score} + out of {total} + ); diff --git a/src/interfaces/module.scores.ts b/src/interfaces/module.scores.ts index cd4b0ea2..622f9ba4 100644 --- a/src/interfaces/module.scores.ts +++ b/src/interfaces/module.scores.ts @@ -5,4 +5,5 @@ export interface ModuleScore { total: number; module: Module | 'Overall'; feedback?: string, + png?: string, } \ No newline at end of file diff --git a/src/pages/api/stats/[id]/export.tsx b/src/pages/api/stats/[id]/export.tsx index 4a8829b9..7c345516 100644 --- a/src/pages/api/stats/[id]/export.tsx +++ b/src/pages/api/stats/[id]/export.tsx @@ -125,6 +125,15 @@ const generateQRCode = async (link: string) => { } }; +type RADIAL_PROGRESS_COLOR = 'laranja' | 'azul'; + +const getRadialProgressPNG = (color: RADIAL_PROGRESS_COLOR, score: number, total: number) => { + const percent = (score / total) * 100; + const remainder = percent % 10; + const roundedPercent = percent - remainder; + return `public/radial_progress/${color}_${roundedPercent}.png`; +} + async function post(req: NextApiRequest, res: NextApiResponse) { if (req.session.user) { const { id } = req.query as { id: string }; @@ -154,7 +163,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) { const user = docUser.data() as User; const stats = docsSnap.docs.map((d) => d.data()); - const results = stats.reduce((accm: ModuleScore[], { module, score }) => { + const results = (stats.reduce((accm: ModuleScore[], { module, score }) => { const fixedModuleStr = module[0].toUpperCase() + module.substring(1); if (accm.find((e: ModuleScore) => e.module === fixedModuleStr)) { return accm.map((e: ModuleScore) => { @@ -179,7 +188,13 @@ async function post(req: NextApiRequest, res: NextApiResponse) { feedback: getFeedback(module), }, ]; - }, []) as ModuleScore[]; + }, []) as ModuleScore[]).map((moduleScore) => { + const { score, total } = moduleScore; + return { + ...moduleScore, + png: getRadialProgressPNG('azul', score, total), + } + }); const [stat] = stats as Stat[]; @@ -196,6 +211,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) { module: "Overall", score: overallScore, total: overallTotal, + png: getRadialProgressPNG('laranja', overallScore, overallTotal), } as ModuleScore; const testDetails = [overallDetail, ...results]; const renderDetails = () => { From e6c82412bf97982931e6a130e27668314b5e94d6 Mon Sep 17 00:00:00 2001 From: Joao Ramos Date: Mon, 8 Jan 2024 01:01:17 +0000 Subject: [PATCH 09/29] Added integration with backend to fetch skills feedback --- src/interfaces/module.scores.ts | 1 + src/pages/api/stats/[id]/export.tsx | 133 +++++++++++++++++++++------- 2 files changed, 104 insertions(+), 30 deletions(-) diff --git a/src/interfaces/module.scores.ts b/src/interfaces/module.scores.ts index 622f9ba4..53ee07fe 100644 --- a/src/interfaces/module.scores.ts +++ b/src/interfaces/module.scores.ts @@ -3,6 +3,7 @@ import {Module} from "@/interfaces"; export interface ModuleScore { score: number; total: number; + code: Module; module: Module | 'Overall'; feedback?: string, png?: string, diff --git a/src/pages/api/stats/[id]/export.tsx b/src/pages/api/stats/[id]/export.tsx index 7c345516..4f889ab7 100644 --- a/src/pages/api/stats/[id]/export.tsx +++ b/src/pages/api/stats/[id]/export.tsx @@ -22,6 +22,10 @@ import { ModuleScore } from "@/interfaces/module.scores"; import qrcode from "qrcode"; import { SkillExamDetails } from "@/exams/pdf/details/skill.exam"; import { LevelExamDetails } from "@/exams/pdf/details/level.exam"; +import { calculateBandScore } from "@/utils/score"; +import axios from "axios"; +import { moduleLabels } from "@/utils/moduleUtils"; + const db = getFirestore(app); export default withIronSessionApiRoute(handler, sessionOptions); @@ -115,6 +119,36 @@ const getFeedback = (module: Module) => { } }; +interface SkillsFeedbackRequest { + code: Module; + name: string; + grade: number; +} + +interface SkillsFeedbackResponse extends SkillsFeedbackRequest { + evaluation: string; + suggestions: string; +} + +const getSkillsFeedback = async (sections: SkillsFeedbackRequest[]) => { + try { + const backendRequest = await axios.post( + `${process.env.BACKEND_URL}/grading_summary`, + { sections }, + { + headers: { + Authorization: `Bearer ${process.env.BACKEND_JWT}`, + }, + } + ); + + return backendRequest.data?.sections; + } catch (err) { + console.log(err); + return null; + } +}; + const generateQRCode = async (link: string) => { try { const qrCodeDataURL = await qrcode.toDataURL(link); @@ -125,14 +159,18 @@ const generateQRCode = async (link: string) => { } }; -type RADIAL_PROGRESS_COLOR = 'laranja' | 'azul'; +type RADIAL_PROGRESS_COLOR = "laranja" | "azul"; -const getRadialProgressPNG = (color: RADIAL_PROGRESS_COLOR, score: number, total: number) => { +const getRadialProgressPNG = ( + color: RADIAL_PROGRESS_COLOR, + score: number, + total: number +) => { const percent = (score / total) * 100; const remainder = percent % 10; const roundedPercent = percent - remainder; return `public/radial_progress/${color}_${roundedPercent}.png`; -} +}; async function post(req: NextApiRequest, res: NextApiResponse) { if (req.session.user) { @@ -163,37 +201,72 @@ async function post(req: NextApiRequest, res: NextApiResponse) { const user = docUser.data() as User; const stats = docsSnap.docs.map((d) => d.data()); - const results = (stats.reduce((accm: ModuleScore[], { module, score }) => { - const fixedModuleStr = module[0].toUpperCase() + module.substring(1); - if (accm.find((e: ModuleScore) => e.module === fixedModuleStr)) { - return accm.map((e: ModuleScore) => { - if (e.module === fixedModuleStr) { - return { - ...e, - score: e.score + score.correct, - total: e.total + score.total, - }; - } + const results = ( + stats.reduce((accm: ModuleScore[], { module, score }) => { + const fixedModuleStr = module[0].toUpperCase() + module.substring(1); + if (accm.find((e: ModuleScore) => e.module === fixedModuleStr)) { + return accm.map((e: ModuleScore) => { + if (e.module === fixedModuleStr) { + return { + ...e, + score: e.score + score.correct, + total: e.total + score.total, + }; + } - return e; - }); - } + return e; + }); + } - return [ - ...accm, - { - module: fixedModuleStr, - score: score.correct, - total: score.total, - feedback: getFeedback(module), - }, - ]; - }, []) as ModuleScore[]).map((moduleScore) => { + return [ + ...accm, + { + module: fixedModuleStr, + score: score.correct, + total: score.total, + feedback: getFeedback(module), + code: module, + }, + ]; + }, []) as ModuleScore[] + ).map((moduleScore) => { const { score, total } = moduleScore; + const bandScore = calculateBandScore( + score, + total, + moduleScore.code as Module, + user.focus + ); + return { ...moduleScore, - png: getRadialProgressPNG('azul', score, total), + png: getRadialProgressPNG("azul", score, total), + bandScore, + }; + }); + + const skillsFeedback = + (await getSkillsFeedback( + results.map(({ code, bandScore }) => ({ + code, + name: moduleLabels[code], + grade: bandScore, + })) + )) || ([] as SkillsFeedbackResponse[]); + + const finalResults = results.map((result) => { + const feedback = skillsFeedback.find( + (f: SkillsFeedbackResponse) => f.code === result.module + ); + + if (feedback) { + return { + ...result, + feedback: feedback?.evaluation + " " + feedback?.suggestions, + }; } + + return result; }); const [stat] = stats as Stat[]; @@ -211,9 +284,9 @@ async function post(req: NextApiRequest, res: NextApiResponse) { module: "Overall", score: overallScore, total: overallTotal, - png: getRadialProgressPNG('laranja', overallScore, overallTotal), + png: getRadialProgressPNG("laranja", overallScore, overallTotal), } as ModuleScore; - const testDetails = [overallDetail, ...results]; + const testDetails = [overallDetail, ...finalResults]; const renderDetails = () => { if (stats[0].module === "level") { return ; From 12d608879d6f0f522505861eda847e26a4ecb8a0 Mon Sep 17 00:00:00 2001 From: Joao Ramos Date: Mon, 8 Jan 2024 18:58:54 +0000 Subject: [PATCH 10/29] Added some code comments --- src/exams/pdf/index.tsx | 10 +- src/pages/api/stats/[id]/export.tsx | 271 ++++++++++++++++------------ 2 files changed, 162 insertions(+), 119 deletions(-) diff --git a/src/exams/pdf/index.tsx b/src/exams/pdf/index.tsx index dd9d5b98..08223f47 100644 --- a/src/exams/pdf/index.tsx +++ b/src/exams/pdf/index.tsx @@ -124,7 +124,10 @@ const PDFReport = ({ {testDetails .filter(({ feedback }) => feedback) .map(({ module, feedback }) => ( - + {module} @@ -142,7 +145,8 @@ const PDFReport = ({ /> - + + {false && ( - + )} { if (module === "level") return getLevelSummary(score); return getExamSummary(score); }; - -const getListeningFeedback = () => - "Your listening skills are exceptional. You display a high level of attentiveness, accurately understanding spoken information across various contexts. Your ability to follow instructions and discern details from spoken content reflects a strong foundation in auditory comprehension. To further refine this skill, continue exposing yourself to diverse listening materials, including podcasts, interviews, and authentic conversations."; -const getReadingFeedback = () => - "Your reading skills are advanced, demonstrating a keen ability to comprehend and analyse written texts. You not only grasp the main ideas effectively but also excel in identifying supporting details and drawing inferences from context. Your enthusiasm for reading is evident, and I encourage you to explore more diverse and challenging materials to further expand your vocabulary and enhance your critical thinking skills."; -const getWritingFeedback = () => - "In the realm of writing, you showcase a commendable command of language. Your ability to construct well-organized and coherent sentences is notable. You exhibit a strong grasp of grammar and punctuation, contributing to the overall clarity of your written expression. Continue refining your writing style, and consider experimenting with different genres to unleash your creative potential."; -const getSpeakingFeedback = () => - "Your oral communication skills are a standout feature of your language proficiency. You articulate ideas with clarity and confidence, actively participating in discussions. Your ability to express yourself verbally is a valuable asset. To enhance your speaking skills even further, consider taking on leadership roles in group activities and engaging in more challenging speaking tasks, such as presentations and debates."; - -const getFeedback = (module: Module) => { - switch (module) { - case "listening": - return getListeningFeedback(); - case "reading": - return getReadingFeedback(); - case "writing": - return getWritingFeedback(); - case "speaking": - return getSpeakingFeedback(); - default: - return ""; - } -}; - interface SkillsFeedbackRequest { code: Module; name: string; @@ -144,7 +119,23 @@ const getSkillsFeedback = async (sections: SkillsFeedbackRequest[]) => { return backendRequest.data?.sections; } catch (err) { - console.log(err); + return err; + } +}; + +const handleSkillsFeedbackRequest = async ( + sections: SkillsFeedbackRequest[] +): Promise => { + let i = 0; + try { + const data = await getSkillsFeedback(sections); + return data; + } catch (err) { + if (i < 3) { + i++; + return handleSkillsFeedbackRequest(sections); + } + return null; } }; @@ -173,15 +164,10 @@ const getRadialProgressPNG = ( }; async function post(req: NextApiRequest, res: NextApiResponse) { + // verify if it's a logged user that is trying to export if (req.session.user) { const { id } = req.query as { id: string }; - // const codeCheckerRef = await getDocs( - // query(collection(db, "codes"), where("checkout", "==", checkout)) - // ); - - // const docRef = doc(db, "stats", id).where; - // const docSnap = await getDoc(docRef); - + // fetch stats entries for this particular user with the requested exam session const docsSnap = await getDocs( query( collection(db, "stats"), @@ -195,106 +181,150 @@ async function post(req: NextApiRequest, res: NextApiResponse) { return; } - const docUser = await getDoc(doc(db, "users", req.session.user.id)); + const stats = docsSnap.docs.map((d) => d.data()); + // verify if the stats already have a pdf generated + const hasPDF = stats.find((s) => s.pdf); - if (docUser.exists()) { - const user = docUser.data() as User; + if (hasPDF) { + // if it does, return the pdf url + res.status(200).end(hasPDF.pdf); + return; + } - const stats = docsSnap.docs.map((d) => d.data()); - const results = ( - stats.reduce((accm: ModuleScore[], { module, score }) => { - const fixedModuleStr = module[0].toUpperCase() + module.substring(1); - if (accm.find((e: ModuleScore) => e.module === fixedModuleStr)) { - return accm.map((e: ModuleScore) => { - if (e.module === fixedModuleStr) { - return { - ...e, - score: e.score + score.correct, - total: e.total + score.total, - }; - } + try { + // generate the pdf report + const docUser = await getDoc(doc(db, "users", req.session.user.id)); - return e; - }); - } + if (docUser.exists()) { + // we'll need the user in order to get the user data (name, email, focus, etc); + const user = docUser.data() as User; - return [ - ...accm, - { - module: fixedModuleStr, - score: score.correct, - total: score.total, - feedback: getFeedback(module), - code: module, - }, - ]; - }, []) as ModuleScore[] - ).map((moduleScore) => { - const { score, total } = moduleScore; - const bandScore = calculateBandScore( - score, - total, - moduleScore.code as Module, - user.focus + // generate the QR code for the report + const qrcode = await generateQRCode( + (req.headers.origin || "") + req.url ); - return { - ...moduleScore, - png: getRadialProgressPNG("azul", score, total), - bandScore, - }; - }); + if (!qrcode) { + res.status(500).json({ ok: false }); + return; + } - const skillsFeedback = - (await getSkillsFeedback( + // stats may contain multiple exams of the same type so we need to aggregate them + const results = ( + stats.reduce((accm: ModuleScore[], { module, score }) => { + const fixedModuleStr = + module[0].toUpperCase() + module.substring(1); + if (accm.find((e: ModuleScore) => e.module === fixedModuleStr)) { + return accm.map((e: ModuleScore) => { + if (e.module === fixedModuleStr) { + return { + ...e, + score: e.score + score.correct, + total: e.total + score.total, + }; + } + + return e; + }); + } + + return [ + ...accm, + { + module: fixedModuleStr, + score: score.correct, + total: score.total, + code: module, + }, + ]; + }, []) as ModuleScore[] + ).map((moduleScore) => { + const { score, total } = moduleScore; + // with all the scores aggreated we can calculate the band score for each module + const bandScore = calculateBandScore( + score, + total, + moduleScore.code as Module, + user.focus + ); + + return { + ...moduleScore, + // generate the closest radial progress png for the score + png: getRadialProgressPNG("azul", score, total), + bandScore, + }; + }); + + // get the skills feedback from the backend based on the module grade + const skillsFeedback = (await handleSkillsFeedbackRequest( results.map(({ code, bandScore }) => ({ code, name: moduleLabels[code], grade: bandScore, })) - )) || ([] as SkillsFeedbackResponse[]); + )) as SkillsFeedbackResponse[]; - const finalResults = results.map((result) => { - const feedback = skillsFeedback.find( - (f: SkillsFeedbackResponse) => f.code === result.module + if (!skillsFeedback) { + res.status(500).json({ ok: false }); + return; + } + + // assign the feedback to the results + const finalResults = results.map((result) => { + const feedback = skillsFeedback.find( + (f: SkillsFeedbackResponse) => f.code === result.code + ); + + if (feedback) { + return { + ...result, + feedback: feedback?.evaluation + " " + feedback?.suggestions, + }; + } + + return result; + }); + + // generate the file ref for storage + const fileName = `${Date.now().toString()}.pdf`; + const fileRef = ref(storage, `exam_report/${fileName}`); + + // calculate the overall score out of all the aggregated results + const overallScore = results.reduce( + (accm, { score }) => accm + score, + 0 + ); + const overallTotal = results.reduce( + (accm, { total }) => accm + total, + 0 + ); + const overallResult = overallScore / overallTotal; + + // generate the performance summary based on the overall result + const performanceSummary = getPerformanceSummary( + "level", + overallResult ); - if (feedback) { - return { - ...result, - feedback: feedback?.evaluation + " " + feedback?.suggestions, - }; - } + // generate the overall detail report + const overallDetail = { + module: "Overall", + score: overallScore, + total: overallTotal, + png: getRadialProgressPNG("laranja", overallScore, overallTotal), + } as ModuleScore; + const testDetails = [overallDetail, ...finalResults]; - return result; - }); + const [stat] = stats; + // level exams have a different report structure than the skill exams + const renderDetails = () => { + if (stat.module === "level") { + return ; + } - const [stat] = stats as Stat[]; - - const fileName = `${Date.now().toString()}.pdf`; - const fileRef = ref(storage, `exam_report/${fileName}`); - const overallScore = results.reduce((accm, { score }) => accm + score, 0); - const overallTotal = results.reduce((accm, { total }) => accm + total, 0); - const overallResult = overallScore / overallTotal; - const performanceSummary = getPerformanceSummary("level", overallResult); - - const qrcode = await generateQRCode((req.headers.origin || "") + req.url); - - const overallDetail = { - module: "Overall", - score: overallScore, - total: overallTotal, - png: getRadialProgressPNG("laranja", overallScore, overallTotal), - } as ModuleScore; - const testDetails = [overallDetail, ...finalResults]; - const renderDetails = () => { - if (stats[0].module === "level") { - return ; - } - - return ; - }; - if (qrcode) { + return ; + }; const pdfStream = await ReactPDF.renderToStream( ); + // upload the pdf to storage const pdfBuffer = await streamToBuffer(pdfStream); const snapshot = await uploadBytes(fileRef, pdfBuffer, { contentType: "application/pdf", }); + + // update the stats entries with the pdf url to prevent duplication docsSnap.docs.forEach(async (doc) => { await updateDoc(doc.ref, { pdf: snapshot.ref.fullPath, @@ -322,6 +355,12 @@ async function post(req: NextApiRequest, res: NextApiResponse) { res.status(200).end(snapshot.ref.fullPath); return; } + + res.status(401).json({ ok: false }); + return; + } catch (err) { + res.status(500).json({ ok: false }); + return; } } From 7328f5c57fae7e6389fd60fea747841c2cfa41a9 Mon Sep 17 00:00:00 2001 From: Joao Ramos Date: Mon, 8 Jan 2024 18:59:51 +0000 Subject: [PATCH 11/29] Temporarily disabled hasPDF validation --- src/pages/api/stats/[id]/export.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/pages/api/stats/[id]/export.tsx b/src/pages/api/stats/[id]/export.tsx index c6082ba8..f50045c0 100644 --- a/src/pages/api/stats/[id]/export.tsx +++ b/src/pages/api/stats/[id]/export.tsx @@ -183,13 +183,13 @@ async function post(req: NextApiRequest, res: NextApiResponse) { const stats = docsSnap.docs.map((d) => d.data()); // verify if the stats already have a pdf generated - const hasPDF = stats.find((s) => s.pdf); + // const hasPDF = stats.find((s) => s.pdf); - if (hasPDF) { - // if it does, return the pdf url - res.status(200).end(hasPDF.pdf); - return; - } + // if (hasPDF) { + // // if it does, return the pdf url + // res.status(200).end(hasPDF.pdf); + // return; + // } try { // generate the pdf report From 0f029a21f70dc6a79a266702e82595e5b0f231b1 Mon Sep 17 00:00:00 2001 From: Joao Ramos Date: Mon, 8 Jan 2024 19:00:23 +0000 Subject: [PATCH 12/29] Added todo notification --- src/pages/api/stats/[id]/export.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/api/stats/[id]/export.tsx b/src/pages/api/stats/[id]/export.tsx index f50045c0..27591f0c 100644 --- a/src/pages/api/stats/[id]/export.tsx +++ b/src/pages/api/stats/[id]/export.tsx @@ -182,7 +182,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) { } const stats = docsSnap.docs.map((d) => d.data()); - // verify if the stats already have a pdf generated + // TODO: verify if the stats already have a pdf generated // const hasPDF = stats.find((s) => s.pdf); // if (hasPDF) { From 63998b50d648ef69716279b2c6a602f601581f7a Mon Sep 17 00:00:00 2001 From: Joao Ramos Date: Mon, 8 Jan 2024 19:04:44 +0000 Subject: [PATCH 13/29] Added more comment --- src/pages/api/stats/[id]/export.tsx | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/pages/api/stats/[id]/export.tsx b/src/pages/api/stats/[id]/export.tsx index 27591f0c..fd4556a2 100644 --- a/src/pages/api/stats/[id]/export.tsx +++ b/src/pages/api/stats/[id]/export.tsx @@ -106,23 +106,20 @@ interface SkillsFeedbackResponse extends SkillsFeedbackRequest { } const getSkillsFeedback = async (sections: SkillsFeedbackRequest[]) => { - try { - const backendRequest = await axios.post( - `${process.env.BACKEND_URL}/grading_summary`, - { sections }, - { - headers: { - Authorization: `Bearer ${process.env.BACKEND_JWT}`, - }, - } - ); + const backendRequest = await axios.post( + `${process.env.BACKEND_URL}/grading_summary`, + { sections }, + { + headers: { + Authorization: `Bearer ${process.env.BACKEND_JWT}`, + }, + } + ); - return backendRequest.data?.sections; - } catch (err) { - return err; - } + return backendRequest.data?.sections; }; +// perform the request with several retries if needed const handleSkillsFeedbackRequest = async ( sections: SkillsFeedbackRequest[] ): Promise => { @@ -150,6 +147,8 @@ const generateQRCode = async (link: string) => { } }; +// Radial Progress PNGs were generated with only two colors +// and they use some baseline score (10%, 20%, 30%..) type RADIAL_PROGRESS_COLOR = "laranja" | "azul"; const getRadialProgressPNG = ( @@ -157,6 +156,8 @@ const getRadialProgressPNG = ( score: number, total: number ) => { + // calculate the percentage of the score + // and round it to the closest available image const percent = (score / total) * 100; const remainder = percent % 10; const roundedPercent = percent - remainder; From 1ea9d8e60f405659c572572f5fd86b00c1d2c50d Mon Sep 17 00:00:00 2001 From: Joao Ramos Date: Mon, 8 Jan 2024 19:17:22 +0000 Subject: [PATCH 14/29] Added custom stylesheet --- src/exams/pdf/details/level.exam.tsx | 65 +++++++++++++------------ src/exams/pdf/details/radial.result.tsx | 63 ++++++++++-------------- src/exams/pdf/details/skill.exam.tsx | 16 +++--- src/exams/pdf/index.tsx | 30 +++++++----- src/exams/pdf/styles.ts | 4 ++ 5 files changed, 89 insertions(+), 89 deletions(-) diff --git a/src/exams/pdf/details/level.exam.tsx b/src/exams/pdf/details/level.exam.tsx index 57ccac20..9230a7c0 100644 --- a/src/exams/pdf/details/level.exam.tsx +++ b/src/exams/pdf/details/level.exam.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { View, Text } from "@react-pdf/renderer"; +import { View, Text, StyleSheet } from "@react-pdf/renderer"; import { ModuleScore } from "@/interfaces/module.scores"; import { styles } from "../styles"; import { RadialResult } from "./radial.result"; @@ -40,6 +40,29 @@ const thresholds = [ }, ]; +const customStyles = StyleSheet.create({ + container: { + display: "flex", + flexDirection: "row", + gap: 30, + justifyContent: "space-between", + }, + tableContainer: { + display: "flex", + flex: 1, + flexDirection: "column", + }, + tableLabel: { + display: "flex", + alignItems: "center", + }, + tableBody: { display: "flex", flex: 1, flexDirection: "row" }, + tableRow: { + display: "flex", + flexDirection: "column", + }, +}); + export const LevelExamDetails = ({ detail }: Props) => { const updatedThresholds = thresholds.map((t) => ({ ...t, @@ -56,47 +79,27 @@ export const LevelExamDetails = ({ detail }: Props) => { return base ? "white" : "#553b25"; }; return ( - + - - + + Level as per CEFR Levels - + {updatedThresholds.map( ({ level, label, minValue, maxValue, match }, index, arr) => ( ( - + {module} - - + + {score} out of {total} diff --git a/src/exams/pdf/details/skill.exam.tsx b/src/exams/pdf/details/skill.exam.tsx index a23e7c69..92f9ab69 100644 --- a/src/exams/pdf/details/skill.exam.tsx +++ b/src/exams/pdf/details/skill.exam.tsx @@ -1,12 +1,5 @@ import React from "react"; -import { - Document, - Page, - View, - Text, - StyleSheet, - Image, -} from "@react-pdf/renderer"; +import { View, StyleSheet } from "@react-pdf/renderer"; import { ModuleScore } from "@/interfaces/module.scores"; import { styles } from "../styles"; import { RadialResult } from "./radial.result"; @@ -14,11 +7,14 @@ interface Props { testDetails: ModuleScore[]; } +const customStyles = StyleSheet.create({ + container: { display: "flex", flexDirection: "row", gap: 30 }, +}); + export const SkillExamDetails = ({ testDetails }: Props) => ( - + {testDetails.map((detail) => { const { module } = detail; - return ; })} diff --git a/src/exams/pdf/index.tsx b/src/exams/pdf/index.tsx index 08223f47..0ec12e7e 100644 --- a/src/exams/pdf/index.tsx +++ b/src/exams/pdf/index.tsx @@ -4,11 +4,23 @@ import { Document, Page, View, Text, Image } from "@react-pdf/renderer"; import ProgressBar from "./progress.bar"; // import RadialProgress from "./radial.progress"; // import RadialProgressSvg from "./radial.progress.svg"; -import { Module } from "@/interfaces"; import { ModuleScore } from "@/interfaces/module.scores"; // import logo from './logo_title.png'; import { styles } from "./styles"; +import { StyleSheet } from "@react-pdf/renderer"; + +const customStyles = StyleSheet.create({ + testDetails: { + display: "flex", + gap: 4, + }, + qrcode: { + width: "80px", + height: "80px", + }, +}); + interface Props { date: string; name: string; @@ -46,7 +58,7 @@ const PDFReport = ({ - + Email: {email} Gender: {gender} - + feedback) .map(({ module, feedback }) => ( - + {module} @@ -138,15 +147,12 @@ const PDFReport = ({ - {false && ( + {false && ( Date: Mon, 8 Jan 2024 19:18:10 +0000 Subject: [PATCH 15/29] Removed unnecessary code --- src/exams/pdf/details/skill.exam.tsx | 1 - src/exams/pdf/index.tsx | 3 --- 2 files changed, 4 deletions(-) diff --git a/src/exams/pdf/details/skill.exam.tsx b/src/exams/pdf/details/skill.exam.tsx index 92f9ab69..eb8a9581 100644 --- a/src/exams/pdf/details/skill.exam.tsx +++ b/src/exams/pdf/details/skill.exam.tsx @@ -1,7 +1,6 @@ import React from "react"; import { View, StyleSheet } from "@react-pdf/renderer"; import { ModuleScore } from "@/interfaces/module.scores"; -import { styles } from "../styles"; import { RadialResult } from "./radial.result"; interface Props { testDetails: ModuleScore[]; diff --git a/src/exams/pdf/index.tsx b/src/exams/pdf/index.tsx index 0ec12e7e..ee7e80d7 100644 --- a/src/exams/pdf/index.tsx +++ b/src/exams/pdf/index.tsx @@ -2,10 +2,7 @@ import React from "react"; import { Document, Page, View, Text, Image } from "@react-pdf/renderer"; import ProgressBar from "./progress.bar"; -// import RadialProgress from "./radial.progress"; -// import RadialProgressSvg from "./radial.progress.svg"; import { ModuleScore } from "@/interfaces/module.scores"; -// import logo from './logo_title.png'; import { styles } from "./styles"; import { StyleSheet } from "@react-pdf/renderer"; From 647807a07c075eabca2f9b434a81d054b336ad2b Mon Sep 17 00:00:00 2001 From: Joao Ramos Date: Mon, 8 Jan 2024 19:27:05 +0000 Subject: [PATCH 16/29] Separated suggestion from evaluation --- src/exams/pdf/index.tsx | 8 +++++--- src/interfaces/module.scores.ts | 3 ++- src/pages/api/stats/[id]/export.tsx | 3 ++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/exams/pdf/index.tsx b/src/exams/pdf/index.tsx index ee7e80d7..846364b1 100644 --- a/src/exams/pdf/index.tsx +++ b/src/exams/pdf/index.tsx @@ -131,13 +131,15 @@ const PDFReport = ({ }} > {testDetails - .filter(({ feedback }) => feedback) - .map(({ module, feedback }) => ( + .filter(({ suggestions, evaluation }) => suggestions || evaluation) + .map(({ module, suggestions, evaluation }) => ( {module} - {feedback} + {evaluation} + {suggestions} + ))} diff --git a/src/interfaces/module.scores.ts b/src/interfaces/module.scores.ts index 53ee07fe..cc109420 100644 --- a/src/interfaces/module.scores.ts +++ b/src/interfaces/module.scores.ts @@ -5,6 +5,7 @@ export interface ModuleScore { total: number; code: Module; module: Module | 'Overall'; - feedback?: string, png?: string, + evaluation?: string, + suggestions?: string, } \ No newline at end of file diff --git a/src/pages/api/stats/[id]/export.tsx b/src/pages/api/stats/[id]/export.tsx index fd4556a2..0b08696f 100644 --- a/src/pages/api/stats/[id]/export.tsx +++ b/src/pages/api/stats/[id]/export.tsx @@ -280,7 +280,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) { if (feedback) { return { ...result, - feedback: feedback?.evaluation + " " + feedback?.suggestions, + evaluation: feedback?.evaluation, + suggestions: feedback?.suggestions, }; } From cd8860f6acd3fad33e5bd454a0f32a7550506c77 Mon Sep 17 00:00:00 2001 From: Joao Ramos Date: Mon, 8 Jan 2024 22:15:54 +0000 Subject: [PATCH 17/29] PDF Report titles are now dynamic --- src/exams/pdf/index.tsx | 4 +++- src/pages/api/stats/[id]/export.tsx | 30 +++++++++++++++++++---------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/exams/pdf/index.tsx b/src/exams/pdf/index.tsx index 846364b1..a81e403d 100644 --- a/src/exams/pdf/index.tsx +++ b/src/exams/pdf/index.tsx @@ -29,9 +29,11 @@ interface Props { logo: string; qrcode: string; renderDetails: React.ReactNode; + title: string; } const PDFReport = ({ + title, date, name, email, @@ -68,7 +70,7 @@ const PDFReport = ({ { fontSize: 14 }, ]} > - English Skills Test Result Report + {title} diff --git a/src/pages/api/stats/[id]/export.tsx b/src/pages/api/stats/[id]/export.tsx index 0b08696f..df16461a 100644 --- a/src/pages/api/stats/[id]/export.tsx +++ b/src/pages/api/stats/[id]/export.tsx @@ -303,12 +303,6 @@ async function post(req: NextApiRequest, res: NextApiResponse) { ); const overallResult = overallScore / overallTotal; - // generate the performance summary based on the overall result - const performanceSummary = getPerformanceSummary( - "level", - overallResult - ); - // generate the overall detail report const overallDetail = { module: "Overall", @@ -319,16 +313,32 @@ async function post(req: NextApiRequest, res: NextApiResponse) { const testDetails = [overallDetail, ...finalResults]; const [stat] = stats; + + // generate the performance summary based on the overall result + const performanceSummary = getPerformanceSummary( + stat.module, + overallResult + ); + // level exams have a different report structure than the skill exams - const renderDetails = () => { + const getCustomData = () => { if (stat.module === "level") { - return ; + return { + title: "ENGLISH LEVEL TEST RESULT REPORT ", + details: , + }; } - return ; + return { + title: "ENGLISH SKILLS TEST RESULT REPORT", + details: , + }; }; + + const { title, details } = getCustomData(); const pdfStream = await ReactPDF.renderToStream( From 2540398ab09a036e339689cf665a74e3fcc810b1 Mon Sep 17 00:00:00 2001 From: Joao Ramos Date: Mon, 8 Jan 2024 22:19:48 +0000 Subject: [PATCH 18/29] Renamed to setup for group testing --- src/exams/pdf/group.test.report.tsx | 33 ++++++++++++++++++++ src/exams/pdf/{index.tsx => test.report.tsx} | 4 +-- src/pages/api/stats/[id]/export.tsx | 4 +-- 3 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 src/exams/pdf/group.test.report.tsx rename src/exams/pdf/{index.tsx => test.report.tsx} (99%) diff --git a/src/exams/pdf/group.test.report.tsx b/src/exams/pdf/group.test.report.tsx new file mode 100644 index 00000000..69791db3 --- /dev/null +++ b/src/exams/pdf/group.test.report.tsx @@ -0,0 +1,33 @@ +/* eslint-disable jsx-a11y/alt-text */ +import React from "react"; +import { Document, Page, View, Text, Image } from "@react-pdf/renderer"; +import { styles } from "./styles"; + +import { StyleSheet } from "@react-pdf/renderer"; + + +interface Props { +// date: string; +// name: string; +// email: string; +// id: string; +// gender?: string; +// testDetails: ModuleScore[]; +// summary: string; +// logo: string; +// qrcode: string; +// renderDetails: React.ReactNode; +// title: string; +} + +const GroupTestReport = ({}: Props) => { + return ( + + + + + + ); +}; + +export default GroupTestReport; diff --git a/src/exams/pdf/index.tsx b/src/exams/pdf/test.report.tsx similarity index 99% rename from src/exams/pdf/index.tsx rename to src/exams/pdf/test.report.tsx index a81e403d..978dcc2f 100644 --- a/src/exams/pdf/index.tsx +++ b/src/exams/pdf/test.report.tsx @@ -32,7 +32,7 @@ interface Props { title: string; } -const PDFReport = ({ +const TestReport = ({ title, date, name, @@ -218,4 +218,4 @@ const PDFReport = ({ ); }; -export default PDFReport; +export default TestReport; diff --git a/src/pages/api/stats/[id]/export.tsx b/src/pages/api/stats/[id]/export.tsx index df16461a..0e8b7e46 100644 --- a/src/pages/api/stats/[id]/export.tsx +++ b/src/pages/api/stats/[id]/export.tsx @@ -13,7 +13,7 @@ import { import { withIronSessionApiRoute } from "iron-session/next"; import { sessionOptions } from "@/lib/session"; import ReactPDF from "@react-pdf/renderer"; -import PDFReport from "@/exams/pdf"; +import TestReport from "@/exams/pdf/test.report"; import { ref, uploadBytes } from "firebase/storage"; import { Stat } from "@/interfaces/user"; import { User } from "@/interfaces/user"; @@ -337,7 +337,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) { const { title, details } = getCustomData(); const pdfStream = await ReactPDF.renderToStream( - Date: Tue, 9 Jan 2024 02:22:54 +0000 Subject: [PATCH 19/29] Added initial group report pdf --- src/exams/pdf/group.test.report.tsx | 254 ++++++++++++- src/exams/pdf/styles.ts | 4 + src/exams/pdf/test.report.footer.tsx | 55 +++ src/exams/pdf/test.report.tsx | 69 +--- src/pages/api/assignments/[id]/export.tsx | 333 ++++++++++++++++++ .../assignments/{[id].tsx => [id]/index.ts} | 0 src/pages/api/stats/[id]/export.tsx | 59 +--- src/utils/pdf.ts | 43 +++ 8 files changed, 687 insertions(+), 130 deletions(-) create mode 100644 src/exams/pdf/test.report.footer.tsx create mode 100644 src/pages/api/assignments/[id]/export.tsx rename src/pages/api/assignments/{[id].tsx => [id]/index.ts} (100%) create mode 100644 src/utils/pdf.ts diff --git a/src/exams/pdf/group.test.report.tsx b/src/exams/pdf/group.test.report.tsx index 69791db3..7e91694f 100644 --- a/src/exams/pdf/group.test.report.tsx +++ b/src/exams/pdf/group.test.report.tsx @@ -1,30 +1,252 @@ /* eslint-disable jsx-a11y/alt-text */ import React from "react"; -import { Document, Page, View, Text, Image } from "@react-pdf/renderer"; +import { + Document, + Page, + View, + Text, + Image, + StyleSheet, +} from "@react-pdf/renderer"; import { styles } from "./styles"; +import TestReportFooter from "./test.report.footer"; +import { ModuleScore } from "@/interfaces/module.scores"; +import ProgressBar from "./progress.bar"; +// import { Font } from "@react-pdf/renderer"; -import { StyleSheet } from "@react-pdf/renderer"; - +// Font.registerHyphenationCallback((word) => [word]); interface Props { -// date: string; -// name: string; -// email: string; -// id: string; -// gender?: string; -// testDetails: ModuleScore[]; -// summary: string; -// logo: string; -// qrcode: string; -// renderDetails: React.ReactNode; -// title: string; + date: string; + name: string; + email: string; + id: string; + gender?: string; + testDetails: ModuleScore[]; + summary: string; + logo: string; + qrcode: string; + renderDetails: React.ReactNode; + title: string; + numberOfStudents: number; + institution: string; + studentsData: any[]; } -const GroupTestReport = ({}: Props) => { +const customStyles = StyleSheet.create({ + tableCellHighlight: { + backgroundColor: "#4f4969", + color: "#bc9970", + }, + table: { + display: "flex", + flexDirection: "column", + // maxWidth: "600px", + // margin: "0 auto", + border: "1px solid #ccc", + // borderCollapse: 'collapse', + }, + tableRow: { + display: "flex", + flexDirection: "row", + borderBottom: "1px solid #ccc", + }, + tableHeader: { + fontWeight: "bold", + backgroundColor: "#f2f2f2", + }, + tableCell: { + flex: 1, + padding: "8px", + textAlign: "left", + wordBreak: "break-all", + }, +}); + +const GroupTestReport = ({ + title, + date, + name, + email, + id, + gender, + testDetails, + summary, + logo, + qrcode, + renderDetails, + numberOfStudents, + institution, + studentsData, +}: Props) => { + const defaultTextStyle = [styles.textFont, { fontSize: 8 }]; + const defaultSkillsTextStyle = [styles.textFont, { fontSize: 8 }]; + const defaultSkillsTitleStyle = [ + styles.textFont, + styles.textColor, + styles.textBold, + { fontSize: 7 }, + ]; + return ( - + + + + + + {title} + + + + Date of Test: {date} + + + Candidate Information: + + + Name: {name} + ID: {id} + Email: {email} + Gender: {gender} + + Total Number of Students: {numberOfStudents} + + Institution: {institution} + + + + Group Test Details: + + {renderDetails} + + + + Group Overall Performance Summary + + + {summary} + + + + + + Group Score Summary + + + {testDetails + .filter( + ({ suggestions, evaluation }) => suggestions || evaluation + ) + .map(({ module, suggestions, evaluation }) => ( + TODO + ))} + + + + + + + {false && ( + + + + )} + + + + + + + Sr + Candidate Name + Email ID + Gender + Date of test + Result + Level + ID + + {studentsData.map( + ({ id, name, email, gender, date, result, level }, index) => ( + + + {index + 1} + + {name} + {email} + {gender} + {date} + {result} + {level} + {id} + + ) + )} + + + + ); diff --git a/src/exams/pdf/styles.ts b/src/exams/pdf/styles.ts index de55c6da..00bbc484 100644 --- a/src/exams/pdf/styles.ts +++ b/src/exams/pdf/styles.ts @@ -47,4 +47,8 @@ export const styles = StyleSheet.create({ height: "64px", width: "64px", }, + qrcode: { + width: "80px", + height: "80px", + }, }); diff --git a/src/exams/pdf/test.report.footer.tsx b/src/exams/pdf/test.report.footer.tsx new file mode 100644 index 00000000..750f2e8c --- /dev/null +++ b/src/exams/pdf/test.report.footer.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { styles } from "./styles"; +import { View, Text } from "@react-pdf/renderer"; + +const TestReportFooter = () => ( + + + + Validity + + This report remains valid for a duration of three months from the test + date. + + + + Confidential – circulated for concern people + + + + Declaration + + We hereby declare that exam results on our platform, assessed by AI, are + not the sole determinants of candidates' English proficiency + levels. While EnCoach provides feedback based on assessments, we + recognize that language proficiency encompasses practical application, + cultural understanding, and real-life communication. We urge users to + consider exam results as a measure of progress and improvement, and we + continuously enhance our system to ensure accuracy and reliability. + + + + info@encoach.com + https://encoach.com + + Group ID: TRI64BNBOIU5043 + + `${pageNumber} / ${totalPages}` + } + fixed + /> + + + +); + +export default TestReportFooter; \ No newline at end of file diff --git a/src/exams/pdf/test.report.tsx b/src/exams/pdf/test.report.tsx index 978dcc2f..f0e5ebc8 100644 --- a/src/exams/pdf/test.report.tsx +++ b/src/exams/pdf/test.report.tsx @@ -1,21 +1,16 @@ /* eslint-disable jsx-a11y/alt-text */ import React from "react"; import { Document, Page, View, Text, Image } from "@react-pdf/renderer"; -import ProgressBar from "./progress.bar"; import { ModuleScore } from "@/interfaces/module.scores"; import { styles } from "./styles"; import { StyleSheet } from "@react-pdf/renderer"; - +import TestReportFooter from "./test.report.footer"; const customStyles = StyleSheet.create({ testDetails: { display: "flex", gap: 4, }, - qrcode: { - width: "80px", - height: "80px", - }, }); interface Props { @@ -148,71 +143,13 @@ const TestReport = ({ - {false && ( - - - - )} - - - - Validity - - This report remains valid for a duration of three months from - the test date. - - - - Confidential – circulated for concern people - - - - Declaration - - We hereby declare that exam results on our platform, assessed by - AI, are not the sole determinants of candidates' English - proficiency levels. While EnCoach provides feedback based on - assessments, we recognize that language proficiency encompasses - practical application, cultural understanding, and real-life - communication. We urge users to consider exam results as a measure - of progress and improvement, and we continuously enhance our - system to ensure accuracy and reliability. - - - - info@encoach.com - https://encoach.com - - Group ID: TRI64BNBOIU5043 - - `${pageNumber} / ${totalPages}` - } - fixed - /> - - - + ); diff --git a/src/pages/api/assignments/[id]/export.tsx b/src/pages/api/assignments/[id]/export.tsx new file mode 100644 index 00000000..2d4ed674 --- /dev/null +++ b/src/pages/api/assignments/[id]/export.tsx @@ -0,0 +1,333 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { app, storage } from "@/firebase"; +import { + getFirestore, + doc, + getDoc, + updateDoc, + getDocs, + query, + collection, + where, + documentId, +} from "firebase/firestore"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import ReactPDF from "@react-pdf/renderer"; +import GroupTestReport from "@/exams/pdf/group.test.report"; +import { ref, uploadBytes } from "firebase/storage"; +import { Stat } from "@/interfaces/user"; +import { User } from "@/interfaces/user"; +import { Module } from "@/interfaces"; +import { ModuleScore } from "@/interfaces/module.scores"; +import qrcode from "qrcode"; +import { SkillExamDetails } from "@/exams/pdf/details/skill.exam"; +import { LevelExamDetails } from "@/exams/pdf/details/level.exam"; +import { calculateBandScore } from "@/utils/score"; +import axios from "axios"; +import { moduleLabels } from "@/utils/moduleUtils"; +import { + generateQRCode, + getRadialProgressPNG, + streamToBuffer, +} from "@/utils/pdf"; + +const db = getFirestore(app); + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "GET") return get(req, res); + if (req.method === "POST") return post(req, res); +} + +const getExamSummary = (score: number) => { + if (score > 0.8) { + return "Scoring between 81% and 100% on the English exam collectively demonstrates an outstanding level of proficiency in writing, speaking, listening, and reading for this group of students. Mastery of key concepts is evident across all language domains, showcasing not only a high level of skill but also a dedication to excellence. The group is encouraged to continue challenging themselves with advanced material in writing, speaking, listening, and reading to further refine their impressive command of the English language."; + } + + if (score > 0.6) { + return "The group's average scores between 61% and 80% on the English exam, encompassing writing, speaking, listening, and reading, reflect a commendable level of proficiency. There's evidence of a solid grasp of key concepts collectively, and effective application of skills. Room for refinement and deeper exploration in writing, speaking, listening, and reading remains, presenting an opportunity for the entire group to further their mastery."; + } + + if (score > 0.4) { + return "Scoring between 41% and 60% on the English exam across writing, speaking, listening, and reading indicates a moderate level of understanding for the group. While there's a commendable grasp of key concepts collectively, refining fundamental skills in writing, speaking, listening, and reading can lead to notable improvement. The group is encouraged to work together with consistent effort and targeted focus on weaker areas."; + } + + if (score > 0.2) { + return "The group's average scores between 21% and 40% on the English exam, encompassing writing, speaking, listening, and reading, show some understanding of key concepts in each domain. However, there's room for improvement in fundamental skills for the entire group. Strengthening writing, speaking, listening, and reading abilities collectively through consistent effort and focused group study will contribute to overall proficiency."; + } + + return "The average performance of this group of students in English, covering writing, speaking, listening, and reading, indicates a collective need for improvement, with scores falling between 0% and 20%. Across all language domains, there's a significant gap in understanding key concepts. Strengthening fundamental skills in writing, speaking, listening, and reading is crucial for the entire group. Implementing a shared, consistent study routine and seeking group support in each area can contribute to substantial progress."; +}; + +const getLevelSummary = (score: number) => { + if (score > 0.8) { + return "Scoring between 81% and 100% on the English exam collectively demonstrates an outstanding level of proficiency for this group of students, showcasing a mastery of key concepts related to vocabulary and grammar. There's evidence of not only a high level of skill but also a dedication to excellence. The group is encouraged to continue challenging themselves with advanced material in vocabulary and grammar to further refine their impressive command of the English language."; + } + + if (score > 0.6) { + return "The group's average scores between 61% and 80% on the English exam reflect a commendable level of proficiency with solid grasp of key concepts related to vocabulary and grammar. Room for refinement and deeper exploration in these language skills remains, presenting an opportunity for the entire group to further their mastery. Consistent effort in honing nuanced aspects of vocabulary and grammar will contribute to even greater proficiency."; + } + + if (score > 0.4) { + return "Scoring between 41% and 60% on the English exam indicates a moderate level of understanding for the group, with commendable grasp of key concepts related to vocabulary and grammar. Refining these fundamental language skills can lead to notable improvement. The group is encouraged to work together with consistent effort and targeted focus on enhancing their vocabulary and grammar."; + } + + if (score > 0.2) { + return "The group's average scores between 21% and 40% on the English exam show some understanding of key concepts in vocabulary and grammar. However, there's room for improvement in these fundamental language skills for the entire group. Strengthening vocabulary and grammar collectively through consistent effort and focused group study will contribute to overall proficiency."; + } + + return "The average performance of this group of students in English suggests a collective need for improvement, with scores falling between 0% and 20%. There's a significant gap in understanding key concepts related to vocabulary and grammar. Strengthening fundamental language skills, such as vocabulary and grammar, is crucial for the entire group. Implementing a shared, consistent study routine and seeking group support in these areas can contribute to substantial progress."; +}; + +const getPerformanceSummary = (module: Module, score: number) => { + if (module === "level") return getLevelSummary(score); + return getExamSummary(score); +}; + +const getScoreAndTotal = (stats: Stat[]) => { + return stats.reduce( + (acc, { score }) => { + return { + ...acc, + correct: acc.correct + score.correct, + total: acc.total + score.total, + }; + }, + { correct: 0, total: 0 } + ); +}; + +async function post(req: NextApiRequest, res: NextApiResponse) { + // verify if it's a logged user that is trying to export + if (req.session.user) { + const { id } = req.query as { id: string }; + + const docSnap = await getDoc(doc(db, "assignments", id)); + const data = docSnap.data() as { + assigner: string; + assignees: string[]; + results: any; + exams: { module: Module }[]; + startDate: string; + }; + if (!data) { + res.status(400).end(); + return; + } + + // TODO: Reenable this + // if (data.assigner !== req.session.user.id) { + // res.status(401).json({ ok: false }); + // return; + // } + + try { + const docUser = await getDoc(doc(db, "users", req.session.user.id)); + if (docUser.exists()) { + // we'll need the user in order to get the user data (name, email, focus, etc); + const user = docUser.data() as User; + + // generate the QR code for the report + const qrcode = await generateQRCode( + (req.headers.origin || "") + req.url + ); + + if (!qrcode) { + res.status(500).json({ ok: false }); + return; + } + + const flattenResults = data.results.reduce( + (accm: Stat[], entry: any) => { + const stats = entry.stats as Stat[]; + return [...accm, ...stats]; + }, + [] + ) as Stat[]; + + const moduleResults = data.exams.map(({ module }) => { + const moduleResults = flattenResults.filter( + (e) => e.module === module + ); + + const { correct, total } = getScoreAndTotal(moduleResults); + const score = calculateBandScore(correct, total, module, "academic"); + const png = getRadialProgressPNG("azul", score, total); + + return { + bandScore: score, + png, + module: module[0].toUpperCase() + module.substring(1), + score, + total, + code: module, + }; + }) as ModuleScore[]; + + const { correct: overallCorrect, total: overallTotal } = + getScoreAndTotal(flattenResults); + const overallResult = overallCorrect / overallTotal; + + // generate the overall detail report + const overallDetail = { + module: "Overall", + score: overallCorrect, + total: overallTotal, + png: getRadialProgressPNG("laranja", overallCorrect, overallTotal), + } as ModuleScore; + + const testDetails = [overallDetail, ...moduleResults]; + // generate the performance summary based on the overall result + const baseStat = data.exams[0]; + const performanceSummary = getPerformanceSummary( + // from what I noticed, exams is either an array with the level module + // or X modules, either way + // as long as I verify the first entry I should be fine + baseStat.module, + overallResult + ); + + // level exams have a different report structure than the skill exams + const getCustomData = () => { + if (baseStat.module === "level") { + return { + title: "GROUP ENGLISH LEVEL TEST RESULT REPORT ", + details: , + }; + } + + return { + title: "GROUP ENGLISH SKILLS TEST RESULT REPORT", + details: , + }; + }; + + const { title, details } = getCustomData(); + + const numberOfStudents = data.assignees.length; + + const getStudentsData = async () => { + // const usersCol = collection(db, "users"); + const docsSnap = await getDocs( + query( + collection(db, "users"), + where(documentId(), "in", data.assignees) + ) + ); + const users = docsSnap.docs.map((d) => ({ + ...d.data(), + id: d.id, + })) as User[]; + + return data.assignees.map((id) => { + const user = users.find((u) => u.id === id); + + if (user) { + const exams = flattenResults.filter((e) => e.user === id); + return { + id, + name: user.name, + email: user.email, + gender: user.demographicInformation?.gender, + date: + exams.length === 0 + ? "N/A" + : new Date(exams[0].date).toLocaleDateString(undefined, { + year: "numeric", + month: "numeric", + day: "numeric", + }), + result: + exams.length === 0 + ? "N/A" + : `${exams[0].score.correct}/${exams[0].score.total}`, + }; + } + + return { + id: "N/A", + name: "N/A", + email: "N/A", + gender: "N/A", + date: "N/A", + result: "N/A", + }; + }); + }; + + const studentsData = await getStudentsData(); + + const pdfStream = await ReactPDF.renderToStream( + + ); + + // generate the file ref for storage + const fileName = `${Date.now().toString()}.pdf`; + const fileRef = ref(storage, `assignment_report/${fileName}`); + + // upload the pdf to storage + const pdfBuffer = await streamToBuffer(pdfStream); + const snapshot = await uploadBytes(fileRef, pdfBuffer, { + contentType: "application/pdf", + }); + + // update the stats entries with the pdf url to prevent duplication + await updateDoc(docSnap.ref, { + pdf: snapshot.ref.fullPath, + }); + res.status(200).end(snapshot.ref.fullPath); + return; + } + + res.status(401).json({ ok: false }); + return; + } catch (err) { + res.status(500).json({ ok: false }); + return; + } + } +} +async function get(req: NextApiRequest, res: NextApiResponse) { + if (req.session.user) { + const { id } = req.query as { id: string }; + + const docSnap = await getDoc(doc(db, "assignments", id)); + const data = docSnap.data(); + if (!data) { + res.status(400).end(); + return; + } + + if (data.assigner !== req.session.user.id) { + res.status(401).json({ ok: false }); + return; + } + + if (data.pdf) { + return res.end(data.pdf); + } + + res.status(404).end(); + return; + } + + res.status(401).json({ ok: false }); + return; +} diff --git a/src/pages/api/assignments/[id].tsx b/src/pages/api/assignments/[id]/index.ts similarity index 100% rename from src/pages/api/assignments/[id].tsx rename to src/pages/api/assignments/[id]/index.ts diff --git a/src/pages/api/stats/[id]/export.tsx b/src/pages/api/stats/[id]/export.tsx index 0e8b7e46..bb465278 100644 --- a/src/pages/api/stats/[id]/export.tsx +++ b/src/pages/api/stats/[id]/export.tsx @@ -25,6 +25,11 @@ import { LevelExamDetails } from "@/exams/pdf/details/level.exam"; import { calculateBandScore } from "@/utils/score"; import axios from "axios"; import { moduleLabels } from "@/utils/moduleUtils"; +import { + generateQRCode, + getRadialProgressPNG, + streamToBuffer, +} from "@/utils/pdf"; const db = getFirestore(app); @@ -35,21 +40,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method === "POST") return post(req, res); } -export const streamToBuffer = async ( - stream: NodeJS.ReadableStream -): Promise => { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - stream.on("data", (data) => { - chunks.push(data); - }); - stream.on("end", () => { - resolve(Buffer.concat(chunks)); - }); - stream.on("error", reject); - }); -}; - const getExamSummary = (score: number) => { if (score > 0.8) { return "Scoring between 81% and 100% on the English exam demonstrates an outstanding level of proficiency in writing, speaking, listening, and reading. Mastery of key concepts is evident across all language domains, showcasing not only a high level of skill but also a dedication to excellence. Continuing to challenge oneself with advanced material in writing, speaking, listening, and reading will further refine the already impressive command of the English language."; @@ -137,33 +127,6 @@ const handleSkillsFeedbackRequest = async ( } }; -const generateQRCode = async (link: string) => { - try { - const qrCodeDataURL = await qrcode.toDataURL(link); - return qrCodeDataURL; - } catch (error) { - console.error("Error generating QR code:", error); - return null; - } -}; - -// Radial Progress PNGs were generated with only two colors -// and they use some baseline score (10%, 20%, 30%..) -type RADIAL_PROGRESS_COLOR = "laranja" | "azul"; - -const getRadialProgressPNG = ( - color: RADIAL_PROGRESS_COLOR, - score: number, - total: number -) => { - // calculate the percentage of the score - // and round it to the closest available image - const percent = (score / total) * 100; - const remainder = percent % 10; - const roundedPercent = percent - remainder; - return `public/radial_progress/${color}_${roundedPercent}.png`; -}; - async function post(req: NextApiRequest, res: NextApiResponse) { // verify if it's a logged user that is trying to export if (req.session.user) { @@ -178,7 +141,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) { ); if (docsSnap.empty) { - res.status(404).end(); + res.status(400).end(); return; } @@ -288,10 +251,6 @@ async function post(req: NextApiRequest, res: NextApiResponse) { return result; }); - // generate the file ref for storage - const fileName = `${Date.now().toString()}.pdf`; - const fileRef = ref(storage, `exam_report/${fileName}`); - // calculate the overall score out of all the aggregated results const overallScore = results.reduce( (accm, { score }) => accm + score, @@ -352,6 +311,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) { /> ); + // generate the file ref for storage + const fileName = `${Date.now().toString()}.pdf`; + const fileRef = ref(storage, `exam_report/${fileName}`); + // upload the pdf to storage const pdfBuffer = await streamToBuffer(pdfStream); const snapshot = await uploadBytes(fileRef, pdfBuffer, { @@ -376,7 +339,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) { } } - res.status(500).json({ ok: false }); + res.status(401).json({ ok: false }); return; } diff --git a/src/utils/pdf.ts b/src/utils/pdf.ts new file mode 100644 index 00000000..16c0f471 --- /dev/null +++ b/src/utils/pdf.ts @@ -0,0 +1,43 @@ +import qrcode from "qrcode"; + +export const generateQRCode = async (link: string) => { + try { + const qrCodeDataURL = await qrcode.toDataURL(link); + return qrCodeDataURL; + } catch (error) { + console.error("Error generating QR code:", error); + return null; + } +}; + +// Radial Progress PNGs were generated with only two colors +// and they use some baseline score (10%, 20%, 30%..) +type RADIAL_PROGRESS_COLOR = "laranja" | "azul"; + +export const getRadialProgressPNG = ( + color: RADIAL_PROGRESS_COLOR, + score: number, + total: number +) => { + // calculate the percentage of the score + // and round it to the closest available image + const percent = (score / total) * 100; + const remainder = percent % 10; + const roundedPercent = percent - remainder; + return `public/radial_progress/${color}_${roundedPercent}.png`; +}; + +export const streamToBuffer = async ( + stream: NodeJS.ReadableStream + ): Promise => { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on("data", (data) => { + chunks.push(data); + }); + stream.on("end", () => { + resolve(Buffer.concat(chunks)); + }); + stream.on("error", reject); + }); + }; \ No newline at end of file From f8bf58e57c261b6584521ff59691507879c25821 Mon Sep 17 00:00:00 2001 From: Joao Ramos Date: Tue, 9 Jan 2024 18:25:00 +0000 Subject: [PATCH 20/29] Removed ID and improved unknown user handling --- src/exams/pdf/group.test.report.tsx | 6 +-- src/pages/api/assignments/[id]/export.tsx | 47 +++++++++-------------- 2 files changed, 21 insertions(+), 32 deletions(-) diff --git a/src/exams/pdf/group.test.report.tsx b/src/exams/pdf/group.test.report.tsx index 7e91694f..31a6aaed 100644 --- a/src/exams/pdf/group.test.report.tsx +++ b/src/exams/pdf/group.test.report.tsx @@ -7,14 +7,14 @@ import { Text, Image, StyleSheet, + Font, } from "@react-pdf/renderer"; import { styles } from "./styles"; import TestReportFooter from "./test.report.footer"; import { ModuleScore } from "@/interfaces/module.scores"; import ProgressBar from "./progress.bar"; -// import { Font } from "@react-pdf/renderer"; -// Font.registerHyphenationCallback((word) => [word]); +Font.registerHyphenationCallback((word) => [word]); interface Props { date: string; @@ -220,7 +220,6 @@ const GroupTestReport = ({ Date of test Result Level - ID {studentsData.map( ({ id, name, email, gender, date, result, level }, index) => ( @@ -239,7 +238,6 @@ const GroupTestReport = ({ {date} {result} {level} - {id} ) )} diff --git a/src/pages/api/assignments/[id]/export.tsx b/src/pages/api/assignments/[id]/export.tsx index 2d4ed674..e913425d 100644 --- a/src/pages/api/assignments/[id]/export.tsx +++ b/src/pages/api/assignments/[id]/export.tsx @@ -223,36 +223,27 @@ async function post(req: NextApiRequest, res: NextApiResponse) { return data.assignees.map((id) => { const user = users.find((u) => u.id === id); - - if (user) { - const exams = flattenResults.filter((e) => e.user === id); - return { - id, - name: user.name, - email: user.email, - gender: user.demographicInformation?.gender, - date: - exams.length === 0 - ? "N/A" - : new Date(exams[0].date).toLocaleDateString(undefined, { - year: "numeric", - month: "numeric", - day: "numeric", - }), - result: - exams.length === 0 - ? "N/A" - : `${exams[0].score.correct}/${exams[0].score.total}`, - }; - } + const exams = flattenResults.filter((e) => e.user === id); + const date = + exams.length === 0 + ? "N/A" + : new Date(exams[0].date).toLocaleDateString(undefined, { + year: "numeric", + month: "numeric", + day: "numeric", + }); + const result = + exams.length === 0 + ? "N/A" + : `${exams[0].score.correct}/${exams[0].score.total}`; return { - id: "N/A", - name: "N/A", - email: "N/A", - gender: "N/A", - date: "N/A", - result: "N/A", + id, + name: user?.name || "N/A", + email: user?.email || "N/A", + gender: user?.demographicInformation?.gender || "N/A", + date, + result, }; }); }; From 4e378f0c711201e3e0527e46f33d46d2a2c00608 Mon Sep 17 00:00:00 2001 From: Joao Ramos Date: Tue, 9 Jan 2024 18:47:32 +0000 Subject: [PATCH 21/29] Added level to table --- src/exams/pdf/group.test.report.tsx | 35 ++++++++++++++------ src/interfaces/module.scores.ts | 10 ++++++ src/pages/api/assignments/[id]/export.tsx | 39 ++++++++++++++++++----- 3 files changed, 67 insertions(+), 17 deletions(-) diff --git a/src/exams/pdf/group.test.report.tsx b/src/exams/pdf/group.test.report.tsx index 31a6aaed..10535510 100644 --- a/src/exams/pdf/group.test.report.tsx +++ b/src/exams/pdf/group.test.report.tsx @@ -11,7 +11,7 @@ import { } from "@react-pdf/renderer"; import { styles } from "./styles"; import TestReportFooter from "./test.report.footer"; -import { ModuleScore } from "@/interfaces/module.scores"; +import { ModuleScore, StudentData } from "@/interfaces/module.scores"; import ProgressBar from "./progress.bar"; Font.registerHyphenationCallback((word) => [word]); @@ -30,7 +30,8 @@ interface Props { title: string; numberOfStudents: number; institution: string; - studentsData: any[]; + studentsData: StudentData[]; + showLevel: boolean; } const customStyles = StyleSheet.create({ @@ -78,6 +79,7 @@ const GroupTestReport = ({ numberOfStudents, institution, studentsData, + showLevel, }: Props) => { const defaultTextStyle = [styles.textFont, { fontSize: 8 }]; const defaultSkillsTextStyle = [styles.textFont, { fontSize: 8 }]; @@ -213,12 +215,20 @@ const GroupTestReport = ({ customStyles.tableCellHighlight, ]} > - Sr + + Sr + Candidate Name Email ID - Gender - Date of test - Result + + Gender + + + Date of test + + + Result + Level {studentsData.map( @@ -228,15 +238,22 @@ const GroupTestReport = ({ style={[ customStyles.tableCell, customStyles.tableCellHighlight, + { maxWidth: "24px" }, ]} > {index + 1} {name} {email} - {gender} - {date} - {result} + + {gender} + + + {date} + + + {result} + {level} ) diff --git a/src/interfaces/module.scores.ts b/src/interfaces/module.scores.ts index cc109420..07d6c65e 100644 --- a/src/interfaces/module.scores.ts +++ b/src/interfaces/module.scores.ts @@ -8,4 +8,14 @@ export interface ModuleScore { png?: string, evaluation?: string, suggestions?: string, + } + + export interface StudentData { + id: string; + name: string; + email: string; + gender: string; + date: string; + result: string; + level?: string; } \ No newline at end of file diff --git a/src/pages/api/assignments/[id]/export.tsx b/src/pages/api/assignments/[id]/export.tsx index e913425d..7e4e0867 100644 --- a/src/pages/api/assignments/[id]/export.tsx +++ b/src/pages/api/assignments/[id]/export.tsx @@ -19,11 +19,11 @@ import { ref, uploadBytes } from "firebase/storage"; import { Stat } from "@/interfaces/user"; import { User } from "@/interfaces/user"; import { Module } from "@/interfaces"; -import { ModuleScore } from "@/interfaces/module.scores"; +import { ModuleScore, StudentData } from "@/interfaces/module.scores"; import qrcode from "qrcode"; import { SkillExamDetails } from "@/exams/pdf/details/skill.exam"; import { LevelExamDetails } from "@/exams/pdf/details/level.exam"; -import { calculateBandScore } from "@/utils/score"; +import { calculateBandScore, getLevelScore } from "@/utils/score"; import axios from "axios"; import { moduleLabels } from "@/utils/moduleUtils"; import { @@ -99,6 +99,17 @@ const getScoreAndTotal = (stats: Stat[]) => { ); }; +const getLevelScoreForUserExams = ( + correct: number, + total: number, + module: Module, + focus: "academic" | "general" +) => { + const bandScore = calculateBandScore(correct, total, module, focus); + const [level] = getLevelScore(bandScore); + return level; +}; + async function post(req: NextApiRequest, res: NextApiResponse) { // verify if it's a logged user that is trying to export if (req.session.user) { @@ -189,9 +200,11 @@ async function post(req: NextApiRequest, res: NextApiResponse) { overallResult ); + const showLevel = baseStat.module === "level"; + // level exams have a different report structure than the skill exams const getCustomData = () => { - if (baseStat.module === "level") { + if (showLevel) { return { title: "GROUP ENGLISH LEVEL TEST RESULT REPORT ", details: , @@ -208,7 +221,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) { const numberOfStudents = data.assignees.length; - const getStudentsData = async () => { + const getStudentsData = async (): Promise => { // const usersCol = collection(db, "users"); const docsSnap = await getDocs( query( @@ -232,10 +245,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) { month: "numeric", day: "numeric", }); - const result = - exams.length === 0 - ? "N/A" - : `${exams[0].score.correct}/${exams[0].score.total}`; + + const { correct, total } = getScoreAndTotal(exams); + + const result = exams.length === 0 ? "N/A" : `${correct}/${total}`; return { id, @@ -244,6 +257,14 @@ async function post(req: NextApiRequest, res: NextApiResponse) { gender: user?.demographicInformation?.gender || "N/A", date, result, + level: showLevel + ? getLevelScoreForUserExams( + correct, + total, + baseStat.module, + user?.focus || "academic" + ) + : "", }; }); }; @@ -266,6 +287,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) { numberOfStudents={numberOfStudents} institution="TODO: PLACEHOLDER" studentsData={studentsData} + showLevel={showLevel} /> ); @@ -290,6 +312,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) { res.status(401).json({ ok: false }); return; } catch (err) { + console.error(err); res.status(500).json({ ok: false }); return; } From 1aadc4647c3c0f0a2b2d7560009bf5cec2fb3dba Mon Sep 17 00:00:00 2001 From: Joao Ramos Date: Tue, 9 Jan 2024 20:17:21 +0000 Subject: [PATCH 22/29] Final improvements for Groups PDF's --- src/exams/pdf/details/level.exam.tsx | 5 +- src/exams/pdf/details/radial.result.tsx | 41 ++----- src/exams/pdf/group.test.report.tsx | 81 ++++++++++--- src/exams/pdf/progress.bar.tsx | 2 +- src/exams/pdf/styles.ts | 19 +++ src/interfaces/module.scores.ts | 1 + src/pages/api/assignments/[id]/export.tsx | 139 ++++++++++++++++------ src/pages/api/stats/[id]/export.tsx | 7 +- 8 files changed, 213 insertions(+), 82 deletions(-) diff --git a/src/exams/pdf/details/level.exam.tsx b/src/exams/pdf/details/level.exam.tsx index 9230a7c0..9ab87a9b 100644 --- a/src/exams/pdf/details/level.exam.tsx +++ b/src/exams/pdf/details/level.exam.tsx @@ -5,6 +5,7 @@ import { styles } from "../styles"; import { RadialResult } from "./radial.result"; interface Props { detail: ModuleScore; + title: string; } const thresholds = [ @@ -63,7 +64,7 @@ const customStyles = StyleSheet.create({ }, }); -export const LevelExamDetails = ({ detail }: Props) => { +export const LevelExamDetails = ({ detail, title }: Props) => { const updatedThresholds = thresholds.map((t) => ({ ...t, match: detail.score >= t.minValue && detail.score <= t.maxValue, @@ -86,7 +87,7 @@ export const LevelExamDetails = ({ detail }: Props) => { - Level as per CEFR Levels + {title} diff --git a/src/exams/pdf/details/radial.result.tsx b/src/exams/pdf/details/radial.result.tsx index 4bec5181..125983a2 100644 --- a/src/exams/pdf/details/radial.result.tsx +++ b/src/exams/pdf/details/radial.result.tsx @@ -1,39 +1,22 @@ /* eslint-disable jsx-a11y/alt-text */ import React from "react"; -import { View, Text, Image, StyleSheet } from "@react-pdf/renderer"; +import { View, Text, Image } from "@react-pdf/renderer"; import { styles } from "../styles"; import { ModuleScore } from "@/interfaces/module.scores"; -const customStyles = StyleSheet.create({ - container: { - display: "flex", - flexDirection: "column", - alignItems: "center", - gap: 4, - position: "relative", - }, - resultContainer: { - display: "flex", - position: "absolute", - top: 0, - left: 0, - width: "100%", - height: "100%", - alignItems: "center", - justifyContent: "center", - fontSize: 10, - gap: 8, - }, -}); - -export const RadialResult = ({ module, score, total, png }: ModuleScore) => ( - - - {module} - +export const RadialResult = ({ + module, + score, + total, + png, +}: ModuleScore) => ( + + + {module} + - + {score} out of {total} diff --git a/src/exams/pdf/group.test.report.tsx b/src/exams/pdf/group.test.report.tsx index 10535510..c7fa48b9 100644 --- a/src/exams/pdf/group.test.report.tsx +++ b/src/exams/pdf/group.test.report.tsx @@ -13,7 +13,6 @@ import { styles } from "./styles"; import TestReportFooter from "./test.report.footer"; import { ModuleScore, StudentData } from "@/interfaces/module.scores"; import ProgressBar from "./progress.bar"; - Font.registerHyphenationCallback((word) => [word]); interface Props { @@ -32,6 +31,9 @@ interface Props { institution: string; studentsData: StudentData[]; showLevel: boolean; + summaryPNG: string; + summaryScore: string; + groupScoreSummary: any[]; } const customStyles = StyleSheet.create({ @@ -44,13 +46,11 @@ const customStyles = StyleSheet.create({ flexDirection: "column", // maxWidth: "600px", // margin: "0 auto", - border: "1px solid #ccc", // borderCollapse: 'collapse', }, tableRow: { display: "flex", flexDirection: "row", - borderBottom: "1px solid #ccc", }, tableHeader: { fontWeight: "bold", @@ -80,6 +80,9 @@ const GroupTestReport = ({ institution, studentsData, showLevel, + summaryPNG, + summaryScore, + groupScoreSummary, }: Props) => { const defaultTextStyle = [styles.textFont, { fontSize: 8 }]; const defaultSkillsTextStyle = [styles.textFont, { fontSize: 8 }]; @@ -150,8 +153,16 @@ const GroupTestReport = ({ > Group Overall Performance Summary - - {summary} + + + {summary} + + + + + {summaryScore} + + @@ -173,13 +184,44 @@ const GroupTestReport = ({ gap: 8, }} > - {testDetails - .filter( - ({ suggestions, evaluation }) => suggestions || evaluation - ) - .map(({ module, suggestions, evaluation }) => ( - TODO + + {groupScoreSummary.map(({ label, percent, description }) => ( + + + {label} + + + + + + {percent} + + {description} + ))} + @@ -205,7 +247,7 @@ const GroupTestReport = ({ style={[ customStyles.table, styles.textFont, - { width: "100%", fontSize: "8px" }, + { border: "1px solid #ccc", width: "100%", fontSize: "8px" }, ]} > @@ -229,11 +272,17 @@ const GroupTestReport = ({ Result - Level + {showLevel && Level} {studentsData.map( ({ id, name, email, gender, date, result, level }, index) => ( - + {result} - {level} + {showLevel && ( + {level} + )} ) )} diff --git a/src/exams/pdf/progress.bar.tsx b/src/exams/pdf/progress.bar.tsx index ef701132..7ee7060c 100644 --- a/src/exams/pdf/progress.bar.tsx +++ b/src/exams/pdf/progress.bar.tsx @@ -40,7 +40,7 @@ const ProgressBar = ({ > diff --git a/src/exams/pdf/styles.ts b/src/exams/pdf/styles.ts index 00bbc484..7cde6859 100644 --- a/src/exams/pdf/styles.ts +++ b/src/exams/pdf/styles.ts @@ -51,4 +51,23 @@ export const styles = StyleSheet.create({ width: "80px", height: "80px", }, + radialContainer: { + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: 4, + position: "relative", + }, + radialResultContainer: { + display: "flex", + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: "100%", + alignItems: "center", + justifyContent: "center", + fontSize: 10, + gap: 8, + }, }); diff --git a/src/interfaces/module.scores.ts b/src/interfaces/module.scores.ts index 07d6c65e..2a4d4149 100644 --- a/src/interfaces/module.scores.ts +++ b/src/interfaces/module.scores.ts @@ -18,4 +18,5 @@ export interface ModuleScore { date: string; result: string; level?: string; + bandScore: number; } \ No newline at end of file diff --git a/src/pages/api/assignments/[id]/export.tsx b/src/pages/api/assignments/[id]/export.tsx index 7e4e0867..d78fbaf3 100644 --- a/src/pages/api/assignments/[id]/export.tsx +++ b/src/pages/api/assignments/[id]/export.tsx @@ -32,6 +32,11 @@ import { streamToBuffer, } from "@/utils/pdf"; +interface GroupScoreSummaryHelper { + score: [number, number]; + label: string; + sessions: string[]; +} const db = getFirestore(app); export default withIronSessionApiRoute(handler, sessionOptions); @@ -99,13 +104,7 @@ const getScoreAndTotal = (stats: Stat[]) => { ); }; -const getLevelScoreForUserExams = ( - correct: number, - total: number, - module: Module, - focus: "academic" | "general" -) => { - const bandScore = calculateBandScore(correct, total, module, focus); +const getLevelScoreForUserExams = (bandScore: number) => { const [level] = getLevelScore(bandScore); return level; }; @@ -158,20 +157,45 @@ async function post(req: NextApiRequest, res: NextApiResponse) { [] ) as Stat[]; + const docsSnap = await getDocs( + query( + collection(db, "users"), + where(documentId(), "in", data.assignees) + ) + ); + const users = docsSnap.docs.map((d) => ({ + ...d.data(), + id: d.id, + })) as User[]; + + const flattenResultsWithGrade = flattenResults.map((e) => { + const focus = users.find((u) => u.id === e.user)?.focus || "academic"; + const bandScore = calculateBandScore( + e.score.correct, + e.score.total, + e.module, + focus + ); + + return { ...e, bandScore }; + }); + const moduleResults = data.exams.map(({ module }) => { - const moduleResults = flattenResults.filter( + const moduleResults = flattenResultsWithGrade.filter( (e) => e.module === module ); + const bandScore = + moduleResults.reduce((accm, curr) => accm + curr.bandScore, 0) / + moduleResults.length; const { correct, total } = getScoreAndTotal(moduleResults); - const score = calculateBandScore(correct, total, module, "academic"); - const png = getRadialProgressPNG("azul", score, total); + const png = getRadialProgressPNG("azul", correct, total); return { - bandScore: score, + bandScore, png, module: module[0].toUpperCase() + module.substring(1), - score, + score: bandScore, total, code: module, }; @@ -181,12 +205,17 @@ async function post(req: NextApiRequest, res: NextApiResponse) { getScoreAndTotal(flattenResults); const overallResult = overallCorrect / overallTotal; + const overallPNG = getRadialProgressPNG( + "laranja", + overallCorrect, + overallTotal + ); // generate the overall detail report const overallDetail = { module: "Overall", score: overallCorrect, total: overallTotal, - png: getRadialProgressPNG("laranja", overallCorrect, overallTotal), + png: overallPNG, } as ModuleScore; const testDetails = [overallDetail, ...moduleResults]; @@ -207,7 +236,12 @@ async function post(req: NextApiRequest, res: NextApiResponse) { if (showLevel) { return { title: "GROUP ENGLISH LEVEL TEST RESULT REPORT ", - details: , + details: ( + + ), }; } @@ -222,21 +256,9 @@ async function post(req: NextApiRequest, res: NextApiResponse) { const numberOfStudents = data.assignees.length; const getStudentsData = async (): Promise => { - // const usersCol = collection(db, "users"); - const docsSnap = await getDocs( - query( - collection(db, "users"), - where(documentId(), "in", data.assignees) - ) - ); - const users = docsSnap.docs.map((d) => ({ - ...d.data(), - id: d.id, - })) as User[]; - return data.assignees.map((id) => { const user = users.find((u) => u.id === id); - const exams = flattenResults.filter((e) => e.user === id); + const exams = flattenResultsWithGrade.filter((e) => e.user === id); const date = exams.length === 0 ? "N/A" @@ -246,6 +268,11 @@ async function post(req: NextApiRequest, res: NextApiResponse) { day: "numeric", }); + const bandScore = + exams.length === 0 + ? 0 + : exams.reduce((accm, curr) => accm + curr.bandScore, 0) / + exams.length; const { correct, total } = getScoreAndTotal(exams); const result = exams.length === 0 ? "N/A" : `${correct}/${total}`; @@ -258,19 +285,60 @@ async function post(req: NextApiRequest, res: NextApiResponse) { date, result, level: showLevel - ? getLevelScoreForUserExams( - correct, - total, - baseStat.module, - user?.focus || "academic" - ) - : "", + ? getLevelScoreForUserExams(bandScore) + : undefined, + bandScore, }; }); }; const studentsData = await getStudentsData(); + const getGroupScoreSummary = () => { + const resultHelper = studentsData.reduce( + (accm: GroupScoreSummaryHelper[], curr) => { + const { bandScore, id } = curr; + + const flooredScore = Math.floor(bandScore); + + const hasMatch = accm.find((a) => a.score.includes(flooredScore)); + if (hasMatch) { + return accm.map((a) => { + if (a.score.includes(flooredScore)) { + return { + ...a, + sessions: [...a.sessions, id], + }; + } + + return a; + }); + } + + return [ + ...accm, + { + score: [flooredScore, flooredScore + 0.5], + label: `${flooredScore} - ${flooredScore + 0.5}`, + sessions: [id], + }, + ]; + }, + [] + ) as GroupScoreSummaryHelper[]; + + const result = resultHelper.map(({ label, sessions }) => { + return { + label, + percent: Math.floor((sessions.length / numberOfStudents) * 100), + description: `No. Candidates ${sessions.length} of ${numberOfStudents}`, + }; + }); + return result; + }; + + const groupScoreSummary = getGroupScoreSummary(); + const pdfStream = await ReactPDF.renderToStream( ); diff --git a/src/pages/api/stats/[id]/export.tsx b/src/pages/api/stats/[id]/export.tsx index bb465278..23f89bc7 100644 --- a/src/pages/api/stats/[id]/export.tsx +++ b/src/pages/api/stats/[id]/export.tsx @@ -284,7 +284,12 @@ async function post(req: NextApiRequest, res: NextApiResponse) { if (stat.module === "level") { return { title: "ENGLISH LEVEL TEST RESULT REPORT ", - details: , + details: ( + + ), }; } From 6c741f944d1572b279649200546f5e2b89ce30f7 Mon Sep 17 00:00:00 2001 From: Joao Ramos Date: Tue, 9 Jan 2024 21:32:07 +0000 Subject: [PATCH 23/29] Minor improvements on labels --- src/exams/pdf/group.test.report.tsx | 15 ++------------- src/pages/api/assignments/[id]/export.tsx | 5 +++-- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/src/exams/pdf/group.test.report.tsx b/src/exams/pdf/group.test.report.tsx index c7fa48b9..e4d94c99 100644 --- a/src/exams/pdf/group.test.report.tsx +++ b/src/exams/pdf/group.test.report.tsx @@ -203,10 +203,10 @@ const GroupTestReport = ({ ]} key={label} > - + {label} - + - {false && ( - - - - )} diff --git a/src/pages/api/assignments/[id]/export.tsx b/src/pages/api/assignments/[id]/export.tsx index d78fbaf3..380cd846 100644 --- a/src/pages/api/assignments/[id]/export.tsx +++ b/src/pages/api/assignments/[id]/export.tsx @@ -327,9 +327,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) { [] ) as GroupScoreSummaryHelper[]; - const result = resultHelper.map(({ label, sessions }) => { + const result = resultHelper.map(({ score, label, sessions }) => { + const finalLabel = showLevel ? getLevelScore(score[0])[1] : label; return { - label, + label: finalLabel, percent: Math.floor((sessions.length / numberOfStudents) * 100), description: `No. Candidates ${sessions.length} of ${numberOfStudents}`, }; From 418221427a20c45e541e92842e4a79e8478f7b7c Mon Sep 17 00:00:00 2001 From: Joao Ramos Date: Tue, 9 Jan 2024 22:42:42 +0000 Subject: [PATCH 24/29] Added pdf download to record page Reenabled reuse of PDF --- src/pages/api/stats/[id]/export.tsx | 37 +++++++------ src/pages/record.tsx | 81 +++++++++++++++++++++++++---- 2 files changed, 91 insertions(+), 27 deletions(-) diff --git a/src/pages/api/stats/[id]/export.tsx b/src/pages/api/stats/[id]/export.tsx index 23f89bc7..a1c3bab5 100644 --- a/src/pages/api/stats/[id]/export.tsx +++ b/src/pages/api/stats/[id]/export.tsx @@ -14,12 +14,10 @@ import { withIronSessionApiRoute } from "iron-session/next"; import { sessionOptions } from "@/lib/session"; import ReactPDF from "@react-pdf/renderer"; import TestReport from "@/exams/pdf/test.report"; -import { ref, uploadBytes } from "firebase/storage"; -import { Stat } from "@/interfaces/user"; +import { ref, uploadBytes, getDownloadURL } from "firebase/storage"; import { User } from "@/interfaces/user"; import { Module } from "@/interfaces"; import { ModuleScore } from "@/interfaces/module.scores"; -import qrcode from "qrcode"; import { SkillExamDetails } from "@/exams/pdf/details/skill.exam"; import { LevelExamDetails } from "@/exams/pdf/details/level.exam"; import { calculateBandScore } from "@/utils/score"; @@ -146,14 +144,17 @@ async function post(req: NextApiRequest, res: NextApiResponse) { } const stats = docsSnap.docs.map((d) => d.data()); - // TODO: verify if the stats already have a pdf generated - // const hasPDF = stats.find((s) => s.pdf); + // verify if the stats already have a pdf generated + const hasPDF = stats.find((s) => s.pdf); - // if (hasPDF) { - // // if it does, return the pdf url - // res.status(200).end(hasPDF.pdf); - // return; - // } + if (hasPDF) { + // if it does, return the pdf url + const fileRef = ref(storage, hasPDF.pdf); + const url = await getDownloadURL(fileRef); + + res.status(200).end(url); + return; + } try { // generate the pdf report @@ -318,7 +319,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) { // generate the file ref for storage const fileName = `${Date.now().toString()}.pdf`; - const fileRef = ref(storage, `exam_report/${fileName}`); + const refName = `exam_report/${fileName}`; + const fileRef = ref(storage, refName); // upload the pdf to storage const pdfBuffer = await streamToBuffer(pdfStream); @@ -329,10 +331,11 @@ async function post(req: NextApiRequest, res: NextApiResponse) { // update the stats entries with the pdf url to prevent duplication docsSnap.docs.forEach(async (doc) => { await updateDoc(doc.ref, { - pdf: snapshot.ref.fullPath, + pdf: refName, }); }); - res.status(200).end(snapshot.ref.fullPath); + const url = await getDownloadURL(fileRef); + res.status(200).end(url); return; } @@ -361,10 +364,12 @@ async function get(req: NextApiRequest, res: NextApiResponse) { const stats = docsSnap.docs.map((d) => d.data()); - const pdfUrl = stats.find((s) => s.pdf); + const hasPDF = stats.find((s) => s.pdf); - if (pdfUrl) { - return res.end(pdfUrl); + if (hasPDF) { + const fileRef = ref(storage, hasPDF.pdf); + const url = await getDownloadURL(fileRef); + return res.redirect(url); } res.status(500).end(); diff --git a/src/pages/record.tsx b/src/pages/record.tsx index 9a934180..80323849 100644 --- a/src/pages/record.tsx +++ b/src/pages/record.tsx @@ -24,6 +24,9 @@ import useGroups from "@/hooks/useGroups"; import {shouldRedirectHome} from "@/utils/navigation.disabled"; import useAssignments from "@/hooks/useAssignments"; import {uuidv4} from "@firebase/util"; +import { BsFilePdf } from "react-icons/bs"; +import axios from "axios"; +import {toast} from "react-toastify"; export const getServerSideProps = withIronSessionSsr(({req, res}) => { const user = req.session.user; @@ -55,6 +58,10 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => { }; }, sessionOptions); +type DownloadingPdf = { + [key: string]: boolean; +}; + export default function History({user}: {user: User}) { const [statsUserId, setStatsUserId] = useState(user.id); const [groupedStats, setGroupedStats] = useState<{[key: string]: Stat[]}>(); @@ -69,7 +76,7 @@ export default function History({user}: {user: User}) { const setShowSolutions = useExamStore((state) => state.setShowSolutions); const setUserSolutions = useExamStore((state) => state.setUserSolutions); const setSelectedModules = useExamStore((state) => state.setSelectedModules); - + const [downloadingPdf, setDownloadingPdf] = useState({}); const router = useRouter(); useEffect(() => { @@ -174,7 +181,7 @@ export default function History({user}: {user: User}) { level: calculateBandScore(x.correct, x.total, x.module, user.focus), })); - const timeSpent = dateStats[0].timeSpent; + const { timeSpent, session } = dateStats[0]; const selectExam = () => { const examPromises = uniqBy(dateStats, "exam").map((stat) => getExamById(stat.module, stat.exam)); @@ -195,6 +202,34 @@ export default function History({user}: {user: User}) { }); }; + const textColor = clsx( + correct / total >= 0.7 && "text-mti-purple", + correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red", + correct / total < 0.3 && "text-mti-rose", + ); + + const triggerDownload = async () => { + try { + setDownloadingPdf((prev) => ({...prev, [session]: true})); + const res = await axios.post(`/api/stats/${session}/export`); + toast.success("Report ready!"); + const link = document.createElement("a"); + link.href = res.data; + // download should have worked but there are some CORS issues + // https://firebase.google.com/docs/storage/web/download-files#cors_configuration + // link.download="report.pdf"; + link.target = '_blank'; + link.rel="noreferrer" + link.click(); + setDownloadingPdf((prev) => ({...prev, [session]: false})); + } catch(err) { + toast.error("Failed to display the report!"); + console.error(err); + setDownloadingPdf((prev) => ({...prev, [session]: false})); + + } + + } const content = ( <>
@@ -207,15 +242,39 @@ export default function History({user}: {user: User}) { )}
- = 0.7 && "text-mti-purple", - correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red", - correct / total < 0.3 && "text-mti-rose", - )}> - Level{" "} - {(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)} - +
From cc0f9712d6cf975f3d4fc1a0a18d898b9d4febcb Mon Sep 17 00:00:00 2001 From: Joao Ramos Date: Tue, 9 Jan 2024 23:15:13 +0000 Subject: [PATCH 25/29] Added download option for assignment cards Export PDF Download to hook Prevented some NaN's --- src/dashboards/AssignmentCard.tsx | 12 +++-- src/dashboards/Teacher.tsx | 2 +- src/hooks/usePDFDownload.tsx | 60 +++++++++++++++++++++++ src/pages/api/assignments/[id]/export.tsx | 41 ++++++++++------ src/pages/record.tsx | 59 ++-------------------- src/utils/pdf.ts | 25 +++++----- 6 files changed, 111 insertions(+), 88 deletions(-) create mode 100644 src/hooks/usePDFDownload.tsx diff --git a/src/dashboards/AssignmentCard.tsx b/src/dashboards/AssignmentCard.tsx index 36fa5a43..52b000c1 100644 --- a/src/dashboards/AssignmentCard.tsx +++ b/src/dashboards/AssignmentCard.tsx @@ -2,19 +2,20 @@ import ProgressBar from "@/components/Low/ProgressBar"; import useUsers from "@/hooks/useUsers"; import {Module} from "@/interfaces"; import {Assignment} from "@/interfaces/results"; -import {Stat} from "@/interfaces/user"; import {calculateBandScore} from "@/utils/score"; import clsx from "clsx"; import moment from "moment"; -import {useState} from "react"; import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs"; +import { usePDFDownload } from "@/hooks/usePDFDownload"; interface Props { onClick?: () => void; + allowDownload?: boolean; } -export default function AssignmentCard({id, name, assigner, startDate, endDate, assignees, results, exams, onClick}: Assignment & Props) { +export default function AssignmentCard({id, name, assigner, startDate, endDate, assignees, results, exams, onClick, allowDownload}: Assignment & Props) { const {users} = useUsers(); + const renderPdfIcon = usePDFDownload("assignments"); const calculateAverageModuleScore = (module: Module) => { const resultModuleBandScores = results.map((r) => { @@ -33,7 +34,10 @@ export default function AssignmentCard({id, name, assigner, startDate, endDate, onClick={onClick} className="w-[350px] h-fit flex flex-col gap-6 bg-white border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
-

{name}

+
+

{name}

+ {allowDownload && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")} +
Past Assignments ({assignments.filter(pastFilter).length})
{assignments.filter(pastFilter).map((a) => ( - setSelectedAssignment(a)} key={a.id} /> + setSelectedAssignment(a)} key={a.id} allowDownload/> ))}
diff --git a/src/hooks/usePDFDownload.tsx b/src/hooks/usePDFDownload.tsx new file mode 100644 index 00000000..54b83884 --- /dev/null +++ b/src/hooks/usePDFDownload.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import axios from "axios"; +import { toast } from "react-toastify"; +import { BsFilePdf } from "react-icons/bs"; + +type DownloadingPdf = { + [key: string]: boolean; +}; + +type PdfEndpoint = "stats" | "assignments"; + +export const usePDFDownload = (endpoint: PdfEndpoint) => { + const [downloadingPdf, setDownloadingPdf] = React.useState( + {} + ); + + const triggerDownload = async (id: string) => { + try { + setDownloadingPdf((prev) => ({ ...prev, [id]: true })); + const res = await axios.post(`/api/${endpoint}/${id}/export`); + toast.success("Report ready!"); + const link = document.createElement("a"); + link.href = res.data; + // download should have worked but there are some CORS issues + // https://firebase.google.com/docs/storage/web/download-files#cors_configuration + // link.download="report.pdf"; + link.target = "_blank"; + link.rel = "noreferrer"; + link.click(); + setDownloadingPdf((prev) => ({ ...prev, [id]: false })); + } catch (err) { + toast.error("Failed to display the report!"); + console.error(err); + setDownloadingPdf((prev) => ({ ...prev, [id]: false })); + } + }; + + const renderIcon = ( + id: string, + downloadClasses: string, + loadingClasses: string + ) => { + if (downloadingPdf[id]) { + return ( + + ); + } + return ( + { + e.stopPropagation(); + triggerDownload(id); + }} + /> + ); + }; + + return renderIcon; +}; diff --git a/src/pages/api/assignments/[id]/export.tsx b/src/pages/api/assignments/[id]/export.tsx index 380cd846..e3bb9e13 100644 --- a/src/pages/api/assignments/[id]/export.tsx +++ b/src/pages/api/assignments/[id]/export.tsx @@ -15,17 +15,14 @@ import { withIronSessionApiRoute } from "iron-session/next"; import { sessionOptions } from "@/lib/session"; import ReactPDF from "@react-pdf/renderer"; import GroupTestReport from "@/exams/pdf/group.test.report"; -import { ref, uploadBytes } from "firebase/storage"; +import { ref, uploadBytes, getDownloadURL } from "firebase/storage"; import { Stat } from "@/interfaces/user"; import { User } from "@/interfaces/user"; import { Module } from "@/interfaces"; import { ModuleScore, StudentData } from "@/interfaces/module.scores"; -import qrcode from "qrcode"; import { SkillExamDetails } from "@/exams/pdf/details/skill.exam"; import { LevelExamDetails } from "@/exams/pdf/details/level.exam"; import { calculateBandScore, getLevelScore } from "@/utils/score"; -import axios from "axios"; -import { moduleLabels } from "@/utils/moduleUtils"; import { generateQRCode, getRadialProgressPNG, @@ -121,17 +118,25 @@ async function post(req: NextApiRequest, res: NextApiResponse) { results: any; exams: { module: Module }[]; startDate: string; + pdf?: string; }; if (!data) { res.status(400).end(); return; } - // TODO: Reenable this - // if (data.assigner !== req.session.user.id) { - // res.status(401).json({ ok: false }); - // return; - // } + if (data.assigner !== req.session.user.id) { + res.status(401).json({ ok: false }); + return; + } + if (data.pdf) { + // if it does, return the pdf url + const fileRef = ref(storage, data.pdf); + const url = await getDownloadURL(fileRef); + + res.status(200).end(url); + return; + } try { const docUser = await getDoc(doc(db, "users", req.session.user.id)); @@ -185,9 +190,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) { (e) => e.module === module ); - const bandScore = + const baseBandScore = moduleResults.reduce((accm, curr) => accm + curr.bandScore, 0) / moduleResults.length; + const bandScore = isNaN(baseBandScore) ? 0 : baseBandScore; const { correct, total } = getScoreAndTotal(moduleResults); const png = getRadialProgressPNG("azul", correct, total); @@ -203,7 +209,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) { const { correct: overallCorrect, total: overallTotal } = getScoreAndTotal(flattenResults); - const overallResult = overallCorrect / overallTotal; + const baseOverallResult = overallCorrect / overallTotal; + const overallResult = isNaN(baseOverallResult) ? 0 : baseOverallResult; const overallPNG = getRadialProgressPNG( "laranja", @@ -365,7 +372,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) { // generate the file ref for storage const fileName = `${Date.now().toString()}.pdf`; - const fileRef = ref(storage, `assignment_report/${fileName}`); + const refName = `assignment_report/${fileName}`; + const fileRef = ref(storage, refName); // upload the pdf to storage const pdfBuffer = await streamToBuffer(pdfStream); @@ -375,9 +383,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) { // update the stats entries with the pdf url to prevent duplication await updateDoc(docSnap.ref, { - pdf: snapshot.ref.fullPath, + pdf: refName, }); - res.status(200).end(snapshot.ref.fullPath); + const url = await getDownloadURL(fileRef); + res.status(200).end(url); return; } @@ -407,7 +416,9 @@ async function get(req: NextApiRequest, res: NextApiResponse) { } if (data.pdf) { - return res.end(data.pdf); + const fileRef = ref(storage, data.pdf); + const url = await getDownloadURL(fileRef); + return res.redirect(url); } res.status(404).end(); diff --git a/src/pages/record.tsx b/src/pages/record.tsx index 80323849..728cbbc5 100644 --- a/src/pages/record.tsx +++ b/src/pages/record.tsx @@ -24,9 +24,7 @@ import useGroups from "@/hooks/useGroups"; import {shouldRedirectHome} from "@/utils/navigation.disabled"; import useAssignments from "@/hooks/useAssignments"; import {uuidv4} from "@firebase/util"; -import { BsFilePdf } from "react-icons/bs"; -import axios from "axios"; -import {toast} from "react-toastify"; +import { usePDFDownload } from "@/hooks/usePDFDownload"; export const getServerSideProps = withIronSessionSsr(({req, res}) => { const user = req.session.user; @@ -58,10 +56,6 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => { }; }, sessionOptions); -type DownloadingPdf = { - [key: string]: boolean; -}; - export default function History({user}: {user: User}) { const [statsUserId, setStatsUserId] = useState(user.id); const [groupedStats, setGroupedStats] = useState<{[key: string]: Stat[]}>(); @@ -76,8 +70,8 @@ export default function History({user}: {user: User}) { const setShowSolutions = useExamStore((state) => state.setShowSolutions); const setUserSolutions = useExamStore((state) => state.setUserSolutions); const setSelectedModules = useExamStore((state) => state.setSelectedModules); - const [downloadingPdf, setDownloadingPdf] = useState({}); const router = useRouter(); + const renderPdfIcon = usePDFDownload("stats"); useEffect(() => { if (stats && !isStatsLoading) { @@ -208,28 +202,6 @@ export default function History({user}: {user: User}) { correct / total < 0.3 && "text-mti-rose", ); - const triggerDownload = async () => { - try { - setDownloadingPdf((prev) => ({...prev, [session]: true})); - const res = await axios.post(`/api/stats/${session}/export`); - toast.success("Report ready!"); - const link = document.createElement("a"); - link.href = res.data; - // download should have worked but there are some CORS issues - // https://firebase.google.com/docs/storage/web/download-files#cors_configuration - // link.download="report.pdf"; - link.target = '_blank'; - link.rel="noreferrer" - link.click(); - setDownloadingPdf((prev) => ({...prev, [session]: false})); - } catch(err) { - toast.error("Failed to display the report!"); - console.error(err); - setDownloadingPdf((prev) => ({...prev, [session]: false})); - - } - - } const content = ( <>
@@ -248,32 +220,7 @@ export default function History({user}: {user: User}) { Level{" "} {(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)} - {/* - { - e.stopPropagation(); - triggerDownload(); - }} - /> - */} - {downloadingPdf[session] ? - : - ( - { - e.stopPropagation(); - triggerDownload(); - }} - /> - ) - } + {renderPdfIcon(session, textColor, textColor)}
diff --git a/src/utils/pdf.ts b/src/utils/pdf.ts index 16c0f471..596737b5 100644 --- a/src/utils/pdf.ts +++ b/src/utils/pdf.ts @@ -22,22 +22,23 @@ export const getRadialProgressPNG = ( // calculate the percentage of the score // and round it to the closest available image const percent = (score / total) * 100; + if (isNaN(percent)) return `public/radial_progress/${color}_0.png`; const remainder = percent % 10; const roundedPercent = percent - remainder; return `public/radial_progress/${color}_${roundedPercent}.png`; }; export const streamToBuffer = async ( - stream: NodeJS.ReadableStream - ): Promise => { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - stream.on("data", (data) => { - chunks.push(data); - }); - stream.on("end", () => { - resolve(Buffer.concat(chunks)); - }); - stream.on("error", reject); + stream: NodeJS.ReadableStream +): Promise => { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on("data", (data) => { + chunks.push(data); }); - }; \ No newline at end of file + stream.on("end", () => { + resolve(Buffer.concat(chunks)); + }); + stream.on("error", reject); + }); +}; From 744aa1e788aff1d676fc5e02138262bfba3dc7de Mon Sep 17 00:00:00 2001 From: Joao Ramos Date: Tue, 9 Jan 2024 23:16:39 +0000 Subject: [PATCH 26/29] Added missing % on percentage Removed unnecessary prop --- src/exams/pdf/group.test.report.tsx | 14 ++------------ src/pages/api/assignments/[id]/export.tsx | 1 - 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/src/exams/pdf/group.test.report.tsx b/src/exams/pdf/group.test.report.tsx index e4d94c99..9b0d0405 100644 --- a/src/exams/pdf/group.test.report.tsx +++ b/src/exams/pdf/group.test.report.tsx @@ -11,7 +11,7 @@ import { } from "@react-pdf/renderer"; import { styles } from "./styles"; import TestReportFooter from "./test.report.footer"; -import { ModuleScore, StudentData } from "@/interfaces/module.scores"; +import { StudentData } from "@/interfaces/module.scores"; import ProgressBar from "./progress.bar"; Font.registerHyphenationCallback((word) => [word]); @@ -21,7 +21,6 @@ interface Props { email: string; id: string; gender?: string; - testDetails: ModuleScore[]; summary: string; logo: string; qrcode: string; @@ -71,7 +70,6 @@ const GroupTestReport = ({ email, id, gender, - testDetails, summary, logo, qrcode, @@ -85,14 +83,6 @@ const GroupTestReport = ({ groupScoreSummary, }: Props) => { const defaultTextStyle = [styles.textFont, { fontSize: 8 }]; - const defaultSkillsTextStyle = [styles.textFont, { fontSize: 8 }]; - const defaultSkillsTitleStyle = [ - styles.textFont, - styles.textColor, - styles.textBold, - { fontSize: 7 }, - ]; - return ( @@ -216,7 +206,7 @@ const GroupTestReport = ({ /> - {percent} + {percent}% {description} diff --git a/src/pages/api/assignments/[id]/export.tsx b/src/pages/api/assignments/[id]/export.tsx index e3bb9e13..b0a7755e 100644 --- a/src/pages/api/assignments/[id]/export.tsx +++ b/src/pages/api/assignments/[id]/export.tsx @@ -356,7 +356,6 @@ async function post(req: NextApiRequest, res: NextApiResponse) { id={user.id} gender={user.demographicInformation?.gender} summary={performanceSummary} - testDetails={testDetails} renderDetails={details} logo={"public/logo_title.png"} qrcode={qrcode} From 2ec7e85aceac1bcd7268b6bc2784545e5249a101 Mon Sep 17 00:00:00 2001 From: Joao Ramos Date: Wed, 10 Jan 2024 21:57:21 +0000 Subject: [PATCH 27/29] Added page break + Improvement footer behaviour --- src/exams/pdf/test.report.footer.tsx | 2 +- src/exams/pdf/test.report.tsx | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/exams/pdf/test.report.footer.tsx b/src/exams/pdf/test.report.footer.tsx index 750f2e8c..e1b4ba31 100644 --- a/src/exams/pdf/test.report.footer.tsx +++ b/src/exams/pdf/test.report.footer.tsx @@ -3,7 +3,7 @@ import { styles } from "./styles"; import { View, Text } from "@react-pdf/renderer"; const TestReportFooter = () => ( - + Email: {email} Gender: {gender} - + + + + Date: Thu, 11 Jan 2024 14:18:04 +0000 Subject: [PATCH 28/29] Download CSV is now also allowed for Agent and Corporates --- src/pages/payment-record.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/pages/payment-record.tsx b/src/pages/payment-record.tsx index 432dcb00..0aacddcb 100644 --- a/src/pages/payment-record.tsx +++ b/src/pages/payment-record.tsx @@ -784,18 +784,20 @@ export default function PaymentRecord() {

Payment Record

- {(user.type === "developer" || user.type === "admin") && ( -
+
+ {(user.type === "developer" || user.type === "admin" || user.type === 'agent' || user.type === 'corporate') && ( + )} + {(user.type === "developer" || user.type === "admin") && ( -
- )} + )} +
From c781c10fe918c1b86974541cccc6e9b04aad5db6 Mon Sep 17 00:00:00 2001 From: Joao Ramos Date: Thu, 11 Jan 2024 14:18:57 +0000 Subject: [PATCH 29/29] Prevented an error that should only happen if the user had the type changed directly on the DB for testing purposes --- src/pages/payment-record.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/payment-record.tsx b/src/pages/payment-record.tsx index 0aacddcb..4090787a 100644 --- a/src/pages/payment-record.tsx +++ b/src/pages/payment-record.tsx @@ -811,14 +811,14 @@ export default function PaymentRecord() { options={(users.filter((u) => u.type === "corporate") as CorporateUser[]).map((user) => ({ value: user.id, meta: user, - label: `${user.corporateInformation.companyInformation.name || user.name} - ${user.email}`, + label: `${user.corporateInformation?.companyInformation?.name || user.name} - ${user.email}`, }))} defaultValue={ user.type === "corporate" ? { value: user.id, meta: user, - label: `${user.corporateInformation.companyInformation.name || user.name} - ${user.email}`, + label: `${user.corporateInformation?.companyInformation?.name || user.name} - ${user.email}`, } : undefined }