From f62efe4347f9f3c99c80e2b75fd78fd72b033889 Mon Sep 17 00:00:00 2001 From: Alec Li Date: Thu, 25 Apr 2024 04:05:45 -0700 Subject: [PATCH] Add calendar to mentor attendance view --- .../section/MentorSectionAttendance.tsx | 274 +++++++++++------- .../src/components/section/Section.tsx | 2 +- .../section/month_calendar/MonthCalendar.tsx | 210 ++++++++++++++ csm_web/frontend/src/css/base/_variables.scss | 6 + csm_web/frontend/src/css/calendar-month.scss | 135 +++++++++ csm_web/frontend/src/css/section.scss | 64 +++- csm_web/frontend/src/utils/types.tsx | 10 +- .../static/frontend/img/angle-left-solid.svg | 1 + .../static/frontend/img/angle-right-solid.svg | 1 + .../frontend/static/frontend/img/calendar.svg | 1 + cypress/e2e/section/mentor-section.cy.ts | 9 + 11 files changed, 593 insertions(+), 120 deletions(-) create mode 100644 csm_web/frontend/src/components/section/month_calendar/MonthCalendar.tsx create mode 100644 csm_web/frontend/src/css/calendar-month.scss create mode 100644 csm_web/frontend/static/frontend/img/angle-left-solid.svg create mode 100644 csm_web/frontend/static/frontend/img/angle-right-solid.svg create mode 100644 csm_web/frontend/static/frontend/img/calendar.svg diff --git a/csm_web/frontend/src/components/section/MentorSectionAttendance.tsx b/csm_web/frontend/src/components/section/MentorSectionAttendance.tsx index 3c91902c..a8cc24ef 100644 --- a/csm_web/frontend/src/components/section/MentorSectionAttendance.tsx +++ b/csm_web/frontend/src/components/section/MentorSectionAttendance.tsx @@ -1,3 +1,4 @@ +import { DateTime } from "luxon"; import randomWords from "random-words"; import React, { useState, useEffect } from "react"; @@ -7,11 +8,13 @@ import { useUpdateWordOfTheDayMutation, useWordOfTheDay } from "../../utils/queries/sections"; -import { Attendance } from "../../utils/types"; +import { Attendance, AttendancePresence } from "../../utils/types"; import LoadingSpinner from "../LoadingSpinner"; import { ATTENDANCE_LABELS } from "./Section"; +import { CalendarMonth } from "./month_calendar/MonthCalendar"; import { dateSortISO, formatDateLocaleShort } from "./utils"; +import CalendarIcon from "../../../static/frontend/img/calendar.svg"; import CheckCircle from "../../../static/frontend/img/check_circle.svg"; import scssColors from "../../css/base/colors-export.module.scss"; @@ -79,12 +82,16 @@ const MentorSectionAttendance = ({ sectionId }: MentorSectionAttendanceProps): R const [responseStatus, setResponseStatus] = useState(ResponseStatus.NONE); const [responseText, setResponseText] = useState(null); + const [calendarVisible, setCalendarVisible] = useState(true); + const [calendarTextMap, setCalendarTextMap] = useState>(new Map()); + /** * Update state based on new fetched attendances */ useEffect(() => { if (jsonAttendancesLoaded) { const newOccurrenceMap = new Map(); + const newCalendarTextMap = new Map(); for (const occurrence of jsonAttendances) { const attendances: Attendance[] = occurrence.attendances .map(({ id, presence, date, studentId, studentName, studentEmail, wordOfTheDayDeadline }) => ({ @@ -97,6 +104,10 @@ const MentorSectionAttendance = ({ sectionId }: MentorSectionAttendanceProps): R })) .sort((att1, att2) => att1.student.name.toLowerCase().localeCompare(att2.student.name.toLowerCase())); newOccurrenceMap.set(occurrence.id, { date: occurrence.date, attendances }); + + const numAttendances = attendances.length; + const numPresent = attendances.filter(att => att.presence == AttendancePresence.PR).length; + newCalendarTextMap.set(occurrence.date, `${numPresent}/${numAttendances}`); } const newSortedOccurrences = Array.from(newOccurrenceMap.entries()) @@ -106,12 +117,18 @@ const MentorSectionAttendance = ({ sectionId }: MentorSectionAttendanceProps): R .map(([occurrenceId, { date: occurrenceDate }]) => ({ id: occurrenceId, date: occurrenceDate })); setOccurrenceMap(newOccurrenceMap); + setCalendarTextMap(newCalendarTextMap); setSortedOccurrences(newSortedOccurrences); let newAttendances = null; if (selectedOccurrence === null) { // only update selected occurrence if it has not been set before - setSelectedOcurrence(newSortedOccurrences[0]); + + // filter for future occurrences + const now = DateTime.now().startOf("day"); + const futureOccurrences = newSortedOccurrences.filter(occurrence => DateTime.fromISO(occurrence.date) >= now); + // set to the most recent future occurrence + setSelectedOcurrence(futureOccurrences[futureOccurrences.length - 1]); newAttendances = newOccurrenceMap.get(newSortedOccurrences[0]?.id)?.attendances; } else { // otherwise use existing selectedOccurrence @@ -139,6 +156,18 @@ const MentorSectionAttendance = ({ sectionId }: MentorSectionAttendanceProps): R } }, [selectedOccurrence, wotd]); + /** + * Whenever the user changes tab, make sure it is in view. + */ + useEffect(() => { + if (selectedOccurrence != null) { + const tab = document.getElementById(`date-tab-${selectedOccurrence.id}`); + if (tab != null) { + tab.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + } + }, [selectedOccurrence]); + /** * Select a new tab, updating the various states * @@ -151,6 +180,10 @@ const MentorSectionAttendance = ({ sectionId }: MentorSectionAttendanceProps): R setResponseText(null); } + function handleToggleCalendar() { + setCalendarVisible(visible => !visible); + } + /** * Change a student's attendance * @@ -161,7 +194,7 @@ const MentorSectionAttendance = ({ sectionId }: MentorSectionAttendanceProps): R const newStagedAttendances = stagedAttendances || occurrenceMap!.get(sortedOccurrences[0].id); setStagedAttendances( newStagedAttendances?.map(attendance => - attendance.id == Number(id) ? { ...attendance, presence: value } : attendance + attendance.id == Number(id) ? { ...attendance, presence: value as AttendancePresence } : attendance ) ); } @@ -203,7 +236,7 @@ const MentorSectionAttendance = ({ sectionId }: MentorSectionAttendanceProps): R setSelectedOcurrence(newSelectedOccurrence); setStagedAttendances(newStagedAttendances); } - setStagedAttendances(stagedAttendances.map(attendance => ({ ...attendance, presence: "PR" }))); + setStagedAttendances(stagedAttendances.map(attendance => ({ ...attendance, presence: AttendancePresence.PR }))); } /** @@ -253,112 +286,141 @@ const MentorSectionAttendance = ({ sectionId }: MentorSectionAttendanceProps): R {jsonAttendancesLoaded && occurrenceMap !== null ? (
-
- {sortedOccurrences.map(({ id, date }) => ( -
handleSelectOccurrence({ id, date })} - > - {formatDateLocaleShort(date)} -
- ))} -
- - - {selectedOccurrence && - stagedAttendances.map(({ id, student, presence }) => { - const cssSuffix = ATTENDANCE_LABELS[presence][1].toLowerCase(); - const attendanceColor = scssColors[`attendance-${cssSuffix}`]; - const attendanceFgColor = scssColors[`attendance-${cssSuffix}-fg`]; - return ( - - - - - ); - })} - -
{student.name} - -
-
- {showSaveSpinner && } - {showAttendanceSaveSuccess && } - - +
+
+ +
+
+ {sortedOccurrences.map(({ id, date }) => ( +
handleSelectOccurrence({ id, date })} + > + {formatDateLocaleShort(date)} +
+ ))} +
-
-

- Word of the Day ({selectedOccurrence ? formatDateLocaleShort(selectedOccurrence.date) : "unselected"}) -

- {wotdLoaded ? ( - -

- Status:{" "} - {initialWordOfTheDay ? ( - Selected - ) : ( - Unselected - )} -

-
-
- { - setWordOfTheDay(e.target.value); - }} - /> - -
-
- {responseStatus === ResponseStatus.LOADING ? ( - - ) : responseStatus === ResponseStatus.OK ? ( - - ) : null} - +
+ + + {selectedOccurrence && + stagedAttendances.map(({ id, student, presence }) => { + const cssSuffix = ATTENDANCE_LABELS[presence][1].toLowerCase(); + const attendanceColor = scssColors[`attendance-${cssSuffix}`]; + const attendanceFgColor = scssColors[`attendance-${cssSuffix}-fg`]; + return ( + + + + + ); + })} + +
{student.name} + +
+
+ {showSaveSpinner && } + {showAttendanceSaveSuccess && } + + +
+
+

+ Word of the Day ( + {selectedOccurrence ? formatDateLocaleShort(selectedOccurrence.date) : "unselected"}) +

+ {wotdLoaded ? ( + +

+ Status:{" "} + {initialWordOfTheDay ? ( + Selected + ) : ( + Unselected + )} +

+
+
+ { + setWordOfTheDay(e.target.value); + }} + /> + +
+
+ {responseStatus === ResponseStatus.LOADING ? ( + + ) : responseStatus === ResponseStatus.OK ? ( + + ) : null} + +
-
-
{responseText}
- - ) : wotdError ? ( -

Error loading word of the day

- ) : ( - - )} +
{responseText}
+ + ) : wotdError ? ( +

Error loading word of the day

+ ) : ( + + )} +
+ {!jsonAttendancesLoaded && }
- {!jsonAttendancesLoaded && }
+ {calendarVisible && ( +
+ occurrence.date)} + occurrenceTextMap={calendarTextMap} + selectedOccurrence={selectedOccurrence?.date} + onClickDate={date => { + // find the section occurrence with the given date + const clickedOccurrence = sortedOccurrences.find(occurrence => occurrence.date === date); + if (clickedOccurrence) { + handleSelectOccurrence(clickedOccurrence); + } + }} + /> +
+ )}
) : ( diff --git a/csm_web/frontend/src/components/section/Section.tsx b/csm_web/frontend/src/components/section/Section.tsx index 769d7919..aff0d204 100644 --- a/csm_web/frontend/src/components/section/Section.tsx +++ b/csm_web/frontend/src/components/section/Section.tsx @@ -64,7 +64,7 @@ export function SectionSidebar({ links }: SectionSidebarProps) { return (