From f8b8efc555d4ad750fb19c0c8ccee77a4fd59867 Mon Sep 17 00:00:00 2001 From: William Jin Date: Fri, 10 Feb 2023 02:34:51 -0800 Subject: [PATCH 1/3] Create parent attendance display #130 --- .../ParentProfile/ParentProfileView.tsx | 7 +- .../ParentProfile/StudentClasses.module.css | 94 +++++++++++++++ .../Profile/ParentProfile/StudentClasses.tsx | 110 ++++++++++++++++++ context/LeagueAPI.ts | 6 + lib/database/classes.ts | 48 +++++++- package-lock.json | 16 +++ package.json | 1 + pages/api/users/[id]/classes/index.ts | 59 ++++++++++ 8 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 components/Profile/ParentProfile/StudentClasses.module.css create mode 100644 components/Profile/ParentProfile/StudentClasses.tsx create mode 100644 pages/api/users/[id]/classes/index.ts diff --git a/components/Profile/ParentProfile/ParentProfileView.tsx b/components/Profile/ParentProfile/ParentProfileView.tsx index 6737c702..3e3dfb85 100644 --- a/components/Profile/ParentProfile/ParentProfileView.tsx +++ b/components/Profile/ParentProfile/ParentProfileView.tsx @@ -9,6 +9,7 @@ import axios from "axios"; import { ConnectStudentProfile } from "./ConnectStudentProfile"; import { ConnectedStudentDisplay } from "./ConnectedStudentDisplay"; import { AddStudentModal } from "./AddStudentModal"; +import { StudentClasses } from "./StudentClasses"; // component that renders the admin/teacher profile page type ParentProfileViewProps = { @@ -126,7 +127,11 @@ const ParentProfileView: React.FC = ({ otherUser }) => { renderStudentCards[currentStudentIndex] )} -
+
+ +
diff --git a/components/Profile/ParentProfile/StudentClasses.module.css b/components/Profile/ParentProfile/StudentClasses.module.css new file mode 100644 index 00000000..65d552be --- /dev/null +++ b/components/Profile/ParentProfile/StudentClasses.module.css @@ -0,0 +1,94 @@ +.spacer { + height: 30px; +} + +.accordion { + height: 100%; + font-family: "Inter"; + font-style: normal; + font-weight: 700; + font-size: 24px; + line-height: 29px; + padding: 1em 1.5em; + overflow-y: auto; + overflow-x: hidden; + color: #000000; +} + +.item { + font-weight: 600; + font-size: 18px; + line-height: 22px; + background: #f3f3f3; +} + +.item + .item { + margin-top: 2em; + border-top: 1px solid rgba(0, 0, 0, 0.1); +} + +.button { + cursor: pointer; + padding: 18px; + width: 100%; + text-align: left; + border: none; + outline: none; + border-radius: 8px; + box-shadow: 0px 5px 10px -11px; +} + +.button:hover { + background-color: #ddd; +} + +.button:before { + float: right; + content: "▼"; + height: 10px; + width: 10px; + margin-right: 12px; +} + +.button[aria-expanded="true"]::before, +.button[aria-selected="true"]::before { + margin-top: 12px; + margin-right: 4px; + transform: rotate(180deg); +} + +.panel { + font-size: 15px; + line-height: 18px; + padding: 20px; + animation: fadein 0.35s ease-in; + border-radius: 0px 0px 8px 8px; +} + +.panel a { + color: blue; +} + +/* -------------------------------------------------- */ +/* ---------------- Animation part ------------------ */ +/* -------------------------------------------------- */ + +@keyframes fadein { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +.info { + display: inline-block; + vertical-align: super; + margin-bottom: 25px; +} + +.info:last-child { + margin-bottom: 0px; +} diff --git a/components/Profile/ParentProfile/StudentClasses.tsx b/components/Profile/ParentProfile/StudentClasses.tsx new file mode 100644 index 00000000..b8cc8331 --- /dev/null +++ b/components/Profile/ParentProfile/StudentClasses.tsx @@ -0,0 +1,110 @@ +import { Class } from "../../../models/"; +import { APIContext } from "../../../context/APIContext"; +import styles from "./StudentClasses.module.css"; +import React, { useContext, useEffect, useState } from "react"; +import RRule from "rrule"; +import { + Accordion, + AccordionItem, + AccordionItemHeading, + AccordionItemButton, + AccordionItemPanel, +} from "react-accessible-accordion"; +import AccessTime from "@mui/icons-material/AccessTime"; +import CalendarMonth from "@mui/icons-material/CalendarMonth"; +import School from "@mui/icons-material/School"; + +type StudentClassesProps = { + id: string; +}; + +const getFormattedTime: string = (time: string) => { + const hours24 = parseInt(time.substring(0, 2)); + const hours = ((hours24 + 11) % 12) + 1; + const hoursText = hours < 10 ? "0" + hours.toString() : hours.toString(); + const amPm = hours24 > 11 ? " pm" : " am"; + const minutes = time.substring(3, 5); + + return hoursText + ":" + minutes + amPm; +}; + +const monthNames = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; + +const StudentClasses: React.FC = ({ id }) => { + const api = useContext(APIContext); + const [classes, setClasses] = useState([]); + + useEffect(() => { + (async () => { + await getClasses(); + })(); + }, [id]); + + const getClasses = async (): Promise => { + if (id) { + const classesByUser = await api.getClassesByUser(id); + setClasses(classesByUser); + } else { + setClasses([]); + } + }; + + return ( + <> + + Classes +
+ {classes.map((Class) => ( + + + + {Class.name}{" "} + {Class.minLevel == Class.maxLevel + ? Class.minLevel + : Class.minLevel + "-" + Class.maxLevel} + + + + {" "} +
+ {getFormattedTime(Class.startTime)} - {getFormattedTime(Class.endTime)} +
+
+ {" "} +
+ {RRule.fromString(Class.rrstring).toText().charAt(0).toUpperCase() + + RRule.fromString(Class.rrstring).toText().slice(1) + + ", start " + + monthNames[RRule.fromString(Class.rrstring).options.dtstart.getMonth()] + + " " + + RRule.fromString(Class.rrstring).options.dtstart.getDate() + + ", " + + RRule.fromString(Class.rrstring).options.dtstart.getFullYear()} +
+
+ {" "} + +
+
+ ))} + + + ); +}; + +export { StudentClasses }; diff --git a/context/LeagueAPI.ts b/context/LeagueAPI.ts index f01d3add..a438babd 100644 --- a/context/LeagueAPI.ts +++ b/context/LeagueAPI.ts @@ -101,6 +101,12 @@ class LeagueAPI { const res = await this.client.get("api/class", { params: { userId: userId } }); return res.data; } + + async getClassesByUser(userId: string): Promise { + const res = await this.client.get(`api/users/${userId}/classes`); + return res.data; + } + async deleteClassEvent(userId: string): Promise { const res = await this.client.delete(`api/events/class/${userId}`); return res.data; diff --git a/lib/database/classes.ts b/lib/database/classes.ts index 0ba5b798..957952b0 100644 --- a/lib/database/classes.ts +++ b/lib/database/classes.ts @@ -31,6 +31,17 @@ type ClassWithoutTeacherInfo = { endTime: string; language: string; }; +const ClassWithoutTeacherInfoSchema = t.type({ + name: t.string, + eventInformationId: t.string, + minLevel: t.number, + maxLevel: t.number, + rrstring: t.string, + startTime: t.string, + endTime: t.string, + language: t.string, +}); +const ClassWithoutTeacherInfoArraySchema = array(ClassWithoutTeacherInfoSchema); const createClass = async ( eventInformationId: string, @@ -196,4 +207,39 @@ const getAllClasses = async (): Promise => { return classesArray; }; -export { createClass, getClass, updateClass, getAllClasses }; +const getClassesByUser = async (id: string): Promise => { + const query = { + text: + "SELECT cl.min_level, cl.max_level, cl.rrstring, cl.start_time, cl.end_time, cl.language, cl.event_information_id, e.name " + + "FROM ((event_information e INNER JOIN classes cl ON e.id = cl.event_information_id) " + + "INNER JOIN commitments ON commitments.event_information_id = e.id) " + + "WHERE commitments.user_id = $1", + values: [id], + }; + const res = await client.query(query); + let classesWithoutTeacher: TypeOf; + try { + classesWithoutTeacher = await decode(ClassWithoutTeacherInfoArraySchema, res.rows); + } catch (e) { + throw Error("Fields returned incorrectly from database"); + } + + const classesArray: Class[] = []; + classesWithoutTeacher.forEach((singleClass) => { + const currClass = { + name: singleClass.name, + eventInformationId: singleClass.eventInformationId, + minLevel: singleClass.minLevel, + maxLevel: singleClass.maxLevel, + rrstring: singleClass.rrstring, + startTime: singleClass.startTime, + endTime: singleClass.endTime, + language: singleClass.language, + teachers: [], + }; + classesArray.push(currClass); + }); + return classesArray; +}; + +export { createClass, getClass, updateClass, getAllClasses, getClassesByUser }; diff --git a/package-lock.json b/package-lock.json index 2a8159fd..5b9d6b89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "pino-pretty": "^9.1.1", "postgres-migrations": "^5.3.0", "react": "17.0.2", + "react-accessible-accordion": "^5.0.0", "react-color": "^2.19.3", "react-datetime-picker": "^3.4.3", "react-dom": "17.0.2", @@ -11186,6 +11187,15 @@ "node": ">=0.10.0" } }, + "node_modules/react-accessible-accordion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-accessible-accordion/-/react-accessible-accordion-5.0.0.tgz", + "integrity": "sha512-MT2obYpTgLIIfPr9d7hEyvPB5rg8uJcHpgA83JSRlEUHvzH48+8HJPvzSs+nM+XprTugDgLfhozO5qyJpBvYRQ==", + "peerDependencies": { + "react": "^16.3.2 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.3.3 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-calendar": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/react-calendar/-/react-calendar-3.9.0.tgz", @@ -21428,6 +21438,12 @@ "object-assign": "^4.1.1" } }, + "react-accessible-accordion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-accessible-accordion/-/react-accessible-accordion-5.0.0.tgz", + "integrity": "sha512-MT2obYpTgLIIfPr9d7hEyvPB5rg8uJcHpgA83JSRlEUHvzH48+8HJPvzSs+nM+XprTugDgLfhozO5qyJpBvYRQ==", + "requires": {} + }, "react-calendar": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/react-calendar/-/react-calendar-3.9.0.tgz", diff --git a/package.json b/package.json index 2f0245fc..c48d92c9 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "pino-pretty": "^9.1.1", "postgres-migrations": "^5.3.0", "react": "17.0.2", + "react-accessible-accordion": "^5.0.0", "react-color": "^2.19.3", "react-datetime-picker": "^3.4.3", "react-dom": "17.0.2", diff --git a/pages/api/users/[id]/classes/index.ts b/pages/api/users/[id]/classes/index.ts new file mode 100644 index 00000000..7fefb544 --- /dev/null +++ b/pages/api/users/[id]/classes/index.ts @@ -0,0 +1,59 @@ +import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; +import { StatusCodes } from "http-status-codes"; +import { getClassesByUser } from "../../../../../lib/database/classes"; +import { withLogging } from "../../../../../middleware/withLogging"; +import { logData, onError } from "../../../../../logger/logger"; + +/** + * @swagger + * /api/users/{id}/classes: + * get: + * description: Get all classes for a single user + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 202: + * description: Found user's classes + * content: + * application/json: + * schema: + * type: array + * items: + * type: object + * $ref: '#/components/schemas/Class' + */ + +export const userClassesHandler: NextApiHandler = async ( + req: NextApiRequest, + res: NextApiResponse +) => { + if (!req.query) { + return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json("Internal Server Error"); + } + + const userId = req.query.id as string; + if (!userId) { + return res.status(StatusCodes.BAD_REQUEST).json("No id specified"); + } + + switch (req.method) { + case "GET": + try { + const result = await getClassesByUser(userId); + logData("Classes of one user: ", result); + return res.status(StatusCodes.ACCEPTED).json(result); + } catch (e) { + onError(e); + return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json("Internal Server Error"); + } + + default: + return res.status(StatusCodes.METHOD_NOT_ALLOWED).json("Method not allowed"); + } +}; + +export default withLogging(userClassesHandler); From a6581659e411beae4657bc1d416e38de12c85bbb Mon Sep 17 00:00:00 2001 From: William Jin Date: Sat, 18 Feb 2023 00:09:43 -0800 Subject: [PATCH 2/3] Address comments --- .../Profile/ParentProfile/StudentClasses.tsx | 35 +++++++++---------- package-lock.json | 14 ++++---- package.json | 2 +- 3 files changed, 24 insertions(+), 27 deletions(-) diff --git a/components/Profile/ParentProfile/StudentClasses.tsx b/components/Profile/ParentProfile/StudentClasses.tsx index b8cc8331..7c448f2d 100644 --- a/components/Profile/ParentProfile/StudentClasses.tsx +++ b/components/Profile/ParentProfile/StudentClasses.tsx @@ -3,6 +3,7 @@ import { APIContext } from "../../../context/APIContext"; import styles from "./StudentClasses.module.css"; import React, { useContext, useEffect, useState } from "react"; import RRule from "rrule"; +import { DateTime } from "luxon"; import { Accordion, AccordionItem, @@ -18,16 +19,6 @@ type StudentClassesProps = { id: string; }; -const getFormattedTime: string = (time: string) => { - const hours24 = parseInt(time.substring(0, 2)); - const hours = ((hours24 + 11) % 12) + 1; - const hoursText = hours < 10 ? "0" + hours.toString() : hours.toString(); - const amPm = hours24 > 11 ? " pm" : " am"; - const minutes = time.substring(3, 5); - - return hoursText + ":" + minutes + amPm; -}; - const monthNames = [ "January", "February", @@ -80,19 +71,25 @@ const StudentClasses: React.FC = ({ id }) => { {" "}
- {getFormattedTime(Class.startTime)} - {getFormattedTime(Class.endTime)} + {DateTime.fromFormat(Class.startTime, "HH:mm:ss.SSSZZ").toFormat("t") + + " - " + + DateTime.fromFormat(Class.endTime, "HH:mm:ss.SSSZZ").toFormat("t")}

{" "}
- {RRule.fromString(Class.rrstring).toText().charAt(0).toUpperCase() + - RRule.fromString(Class.rrstring).toText().slice(1) + - ", start " + - monthNames[RRule.fromString(Class.rrstring).options.dtstart.getMonth()] + - " " + - RRule.fromString(Class.rrstring).options.dtstart.getDate() + - ", " + - RRule.fromString(Class.rrstring).options.dtstart.getFullYear()} + { + // Capitalize the first letter of the occurrence + start the occurrence string + // at the second char + month number to month name + day of month + full year + RRule.fromString(Class.rrstring).toText().charAt(0).toUpperCase() + + RRule.fromString(Class.rrstring).toText().slice(1) + + ", start " + + monthNames[RRule.fromString(Class.rrstring).options.dtstart.getMonth()] + + " " + + RRule.fromString(Class.rrstring).options.dtstart.getDate() + + ", " + + RRule.fromString(Class.rrstring).options.dtstart.getFullYear() + }

{" "} diff --git a/package-lock.json b/package-lock.json index 5b9d6b89..9a8a5e8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,7 @@ "io-ts": "^2.2.16", "io-ts-promise": "^2.0.2", "jest-node-exports-resolver": "^1.1.6", - "luxon": "^2.3.1", + "luxon": "^2.5.2", "moment": "^2.29.1", "next": "^12.1.0", "next-swagger-doc": "^0.3.6", @@ -9682,9 +9682,9 @@ "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=" }, "node_modules/luxon": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-2.4.0.tgz", - "integrity": "sha512-w+NAwWOUL5hO0SgwOHsMBAmZ15SoknmQXhSO0hIbJCAmPKSsGeK8MlmhYh2w6Iib38IxN2M+/ooXWLbeis7GuA==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-2.5.2.tgz", + "integrity": "sha512-Yg7/RDp4nedqmLgyH0LwgGRvMEKVzKbUdkBYyCosbHgJ+kaOUx0qzSiSatVc3DFygnirTPYnMM2P5dg2uH1WvA==", "engines": { "node": ">=12" } @@ -20287,9 +20287,9 @@ } }, "luxon": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-2.4.0.tgz", - "integrity": "sha512-w+NAwWOUL5hO0SgwOHsMBAmZ15SoknmQXhSO0hIbJCAmPKSsGeK8MlmhYh2w6Iib38IxN2M+/ooXWLbeis7GuA==" + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-2.5.2.tgz", + "integrity": "sha512-Yg7/RDp4nedqmLgyH0LwgGRvMEKVzKbUdkBYyCosbHgJ+kaOUx0qzSiSatVc3DFygnirTPYnMM2P5dg2uH1WvA==" }, "lz-string": { "version": "1.4.4", diff --git a/package.json b/package.json index c48d92c9..4db8a663 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "io-ts": "^2.2.16", "io-ts-promise": "^2.0.2", "jest-node-exports-resolver": "^1.1.6", - "luxon": "^2.3.1", + "luxon": "^2.5.2", "moment": "^2.29.1", "next": "^12.1.0", "next-swagger-doc": "^0.3.6", From 66b63f2dfd4d4314a4ab6198b203425f6ae09ffb Mon Sep 17 00:00:00 2001 From: William Jin Date: Sat, 18 Feb 2023 00:19:29 -0800 Subject: [PATCH 3/3] Use luxon on JSDate --- .../Profile/ParentProfile/StudentClasses.tsx | 26 +++---------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/components/Profile/ParentProfile/StudentClasses.tsx b/components/Profile/ParentProfile/StudentClasses.tsx index 7c448f2d..5fbf4b5b 100644 --- a/components/Profile/ParentProfile/StudentClasses.tsx +++ b/components/Profile/ParentProfile/StudentClasses.tsx @@ -19,21 +19,6 @@ type StudentClassesProps = { id: string; }; -const monthNames = [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", -]; - const StudentClasses: React.FC = ({ id }) => { const api = useContext(APIContext); const [classes, setClasses] = useState([]); @@ -79,16 +64,13 @@ const StudentClasses: React.FC = ({ id }) => { {" "}
{ - // Capitalize the first letter of the occurrence + start the occurrence string - // at the second char + month number to month name + day of month + full year + // Capitalize the first letter of the sentence RRule.fromString(Class.rrstring).toText().charAt(0).toUpperCase() + RRule.fromString(Class.rrstring).toText().slice(1) + ", start " + - monthNames[RRule.fromString(Class.rrstring).options.dtstart.getMonth()] + - " " + - RRule.fromString(Class.rrstring).options.dtstart.getDate() + - ", " + - RRule.fromString(Class.rrstring).options.dtstart.getFullYear() + DateTime.fromJSDate(RRule.fromString(Class.rrstring).options.dtstart).toFormat( + "DDD" + ) }