From 44bca40f9ef7ab163e64c49c1df285a4a4be6700 Mon Sep 17 00:00:00 2001 From: Alec Li Date: Sun, 16 Jul 2023 18:08:34 -0700 Subject: [PATCH] Add luxon, refactor date parsing/formatting Use luxon for enrollment automation implementation Update resource aggregation date display --- .../frontend/src/components/CourseMenu.tsx | 31 +- .../frontend/src/components/course/Course.tsx | 19 +- .../EnrollmentAutomationTypes.tsx | 5 +- .../MentorSectionPreferences.tsx | 14 +- .../calendar/Calendar.tsx | 287 ++++++++++++------ .../calendar/CalendarDay.tsx | 103 +++---- .../coordinator/ConfigureStage.tsx | 4 +- .../coordinator/CoordinatorMatcherForm.tsx | 6 +- .../coordinator/CreateStage.tsx | 135 ++++---- .../coordinator/EditStage.tsx | 23 +- .../coordinator/ReleaseStage.tsx | 6 +- .../enrollment_automation/utils.tsx | 56 +--- .../ResourceRowRender.tsx | 4 +- .../resource_aggregation/ResourceTable.tsx | 13 +- .../section/CoordinatorAddStudentModal.tsx | 3 +- .../section/MentorSectionAttendance.tsx | 6 +- .../components/section/SpacetimeEditModal.tsx | 6 +- .../src/components/section/StudentSection.tsx | 65 +--- .../frontend/src/components/section/utils.tsx | 77 +---- csm_web/frontend/src/utils/datetime.tsx | 57 ++++ csm_web/frontend/src/utils/types.tsx | 4 +- csm_web/scheduler/serializers.py | 4 +- cypress/e2e/course/coordinator-course.cy.ts | 23 +- package-lock.json | 27 ++ package.json | 2 + 25 files changed, 509 insertions(+), 471 deletions(-) create mode 100644 csm_web/frontend/src/utils/datetime.tsx diff --git a/csm_web/frontend/src/components/CourseMenu.tsx b/csm_web/frontend/src/components/CourseMenu.tsx index 3ea63fed..213203e8 100644 --- a/csm_web/frontend/src/components/CourseMenu.tsx +++ b/csm_web/frontend/src/components/CourseMenu.tsx @@ -5,6 +5,8 @@ import { useUserInfo } from "../utils/queries/base"; import { Course as CourseType, UserInfo } from "../utils/types"; import Course from "./course/Course"; import LoadingSpinner from "./LoadingSpinner"; +import { DateTime } from "luxon"; +import { DEFAULT_LONG_LOCALE_OPTIONS, DEFAULT_TIMEZONE } from "../utils/datetime"; const CourseMenu = () => { const { data: jsonCourses, isSuccess: coursesLoaded } = useCourses(); @@ -33,7 +35,7 @@ const CourseMenu = () => { if (userInfoLoaded) { let priorityEnrollment = undefined; if (jsonUserInfo.priorityEnrollment) { - priorityEnrollment = new Date(Date.parse(jsonUserInfo.priorityEnrollment)); + priorityEnrollment = DateTime.fromISO(jsonUserInfo.priorityEnrollment); } const convertedUserInfo: UserInfo = { ...jsonUserInfo, @@ -46,7 +48,7 @@ const CourseMenu = () => { } let show_enrollment_times = false; - const enrollment_times_by_course: Array<{ courseName: string; enrollmentDate: Date }> = []; + const enrollment_times_by_course: Array<{ courseName: string; enrollmentDate: DateTime }> = []; if (courses !== null) { for (const course of courses.values()) { @@ -54,7 +56,7 @@ const CourseMenu = () => { if (!course.enrollmentOpen) { enrollment_times_by_course.push({ courseName: course.name, - enrollmentDate: new Date(Date.parse(course.enrollmentStart)) + enrollmentDate: DateTime.fromISO(course.enrollmentStart, { zone: DEFAULT_TIMEZONE }) }); } } @@ -97,7 +99,7 @@ interface CourseMenuContentProps { courses: Map | null; coursesLoaded: boolean; userInfo: UserInfo | null; - enrollment_times_by_course: Array<{ courseName: string; enrollmentDate: Date }>; + enrollment_times_by_course: Array<{ courseName: string; enrollmentDate: DateTime }>; } enum CourseMenuSidebarTabs { @@ -217,7 +219,7 @@ const EnrollmentMenu = ({ courses }: EnrollmentMenuProps) => { interface EnrollmentTimesProps { coursesLoaded: boolean; userInfo: UserInfo | null; - enrollmentTimes: Array<{ courseName: string; enrollmentDate: Date }>; + enrollmentTimes: Array<{ courseName: string; enrollmentDate: DateTime }>; } /** @@ -237,19 +239,6 @@ const EnrollmentTimes = ({ return null; } - /** - * Formatting for the enrollment times. - */ - const date_locale_string_options: Intl.DateTimeFormatOptions = { - weekday: "long", - month: "numeric", - day: "numeric", - hour: "numeric", - minute: "numeric", - hour12: true, - timeZoneName: "short" - }; - if (enrollmentTimes.length === 0) { return null; } @@ -266,16 +255,14 @@ const EnrollmentTimes = ({ Priority
- {`${userInfo.priorityEnrollment.toLocaleDateString("en-US", date_locale_string_options)}`} + {`${userInfo.priorityEnrollment.toLocaleString(DEFAULT_LONG_LOCALE_OPTIONS)}`}
)} {enrollmentTimes.map(({ courseName, enrollmentDate }) => (
{courseName}
-
- {`${enrollmentDate.toLocaleDateString("en-US", date_locale_string_options)}`} -
+
{`${enrollmentDate.toLocaleString(DEFAULT_LONG_LOCALE_OPTIONS)}`}
))} diff --git a/csm_web/frontend/src/components/course/Course.tsx b/csm_web/frontend/src/components/course/Course.tsx index b1342a35..8dbe9314 100644 --- a/csm_web/frontend/src/components/course/Course.tsx +++ b/csm_web/frontend/src/components/course/Course.tsx @@ -10,6 +10,8 @@ import { WhitelistModal } from "./WhitelistModal"; import { SettingsModal } from "./SettingsModal"; import PencilIcon from "../../../static/frontend/img/pencil.svg"; +import { DateTime } from "luxon"; +import { DEFAULT_LONG_LOCALE_OPTIONS } from "../../utils/datetime"; const DAY_OF_WEEK_ABREVIATIONS: { [day: string]: string } = Object.freeze({ Monday: "M", @@ -35,8 +37,8 @@ interface CourseProps { * CourseMenu is done with its API requests, making the user suffer twice the latency for no reason. */ courses: Map | null; - enrollmentTimes: Array<{ courseName: string; enrollmentDate: Date }>; - priorityEnrollment: Date | undefined; + enrollmentTimes: Array<{ courseName: string; enrollmentDate: DateTime }>; + priorityEnrollment: DateTime | undefined; } const Course = ({ courses, priorityEnrollment, enrollmentTimes }: CourseProps): React.ReactElement | null => { @@ -118,20 +120,9 @@ const Course = ({ courses, priorityEnrollment, enrollmentTimes }: CourseProps): currDaySections = currDaySections.filter(({ numStudentsEnrolled, capacity }) => numStudentsEnrolled < capacity); } - // copied from CourseMenu.tsx - const date_locale_string_options: Intl.DateTimeFormatOptions = { - weekday: "long", - month: "numeric", - day: "numeric", - hour: "numeric", - minute: "numeric", - hour12: true, - timeZoneName: "short" - }; - const enrollmentDate = priorityEnrollment ?? enrollmentTimes.find(({ courseName }) => courseName == course.name)?.enrollmentDate; - const enrollmentTimeString = enrollmentDate?.toLocaleDateString("en-US", date_locale_string_options) ?? ""; + const enrollmentTimeString = enrollmentDate?.toLocaleString(DEFAULT_LONG_LOCALE_OPTIONS) ?? ""; return (
diff --git a/csm_web/frontend/src/components/enrollment_automation/EnrollmentAutomationTypes.tsx b/csm_web/frontend/src/components/enrollment_automation/EnrollmentAutomationTypes.tsx index 16121e84..13d8b971 100644 --- a/csm_web/frontend/src/components/enrollment_automation/EnrollmentAutomationTypes.tsx +++ b/csm_web/frontend/src/components/enrollment_automation/EnrollmentAutomationTypes.tsx @@ -1,7 +1,8 @@ +import { Interval } from "luxon"; + export interface Time { day: string; - startTime: number; - endTime: number; + interval: Interval; isLinked: boolean; } diff --git a/csm_web/frontend/src/components/enrollment_automation/MentorSectionPreferences.tsx b/csm_web/frontend/src/components/enrollment_automation/MentorSectionPreferences.tsx index 19787eba..e9ee6c2b 100644 --- a/csm_web/frontend/src/components/enrollment_automation/MentorSectionPreferences.tsx +++ b/csm_web/frontend/src/components/enrollment_automation/MentorSectionPreferences.tsx @@ -1,14 +1,17 @@ +import { Interval } from "luxon"; import React, { useEffect, useState } from "react"; + import { Profile } from "../../utils/types"; import LoadingSpinner from "../LoadingSpinner"; import { Calendar } from "./calendar/Calendar"; import { CalendarEvent, CalendarEventSingleTime } from "./calendar/CalendarTypes"; import { Slot } from "./EnrollmentAutomationTypes"; -import { formatInterval, formatTime, MAX_PREFERENCE, parseTime } from "./utils"; +import { MAX_PREFERENCE, parseTime } from "./utils"; +import { useMatcherPreferenceMutation, useMatcherPreferences, useMatcherSlots } from "../../utils/queries/matcher"; +import { formatInterval } from "../../utils/datetime"; import CheckCircle from "../../../static/frontend/img/check_circle.svg"; import ErrorCircle from "../../../static/frontend/img/x_circle.svg"; -import { useMatcherPreferenceMutation, useMatcherPreferences, useMatcherSlots } from "../../utils/queries/matcher"; enum Status { NONE, @@ -56,8 +59,7 @@ export function MentorSectionPreferences({ for (const time of slot.times) { times.push({ day: time.day, - startTime: parseTime(time.startTime), - endTime: parseTime(time.endTime), + interval: Interval.fromDateTimes(parseTime(time.startTime), parseTime(time.endTime)), isLinked: time.isLinked }); } @@ -153,7 +155,7 @@ export function MentorSectionPreferences({ return ( - {formatInterval(event.time.startTime, event.time.endTime)} + {formatInterval(event.time.interval)} {detail} ); @@ -176,7 +178,7 @@ export function MentorSectionPreferences({ {event.times.map((time, time_idx) => (
  • - {time.day} {formatTime(time.startTime)}–{formatTime(time.endTime)} + {time.day} {formatInterval(time.interval)}
  • ))} diff --git a/csm_web/frontend/src/components/enrollment_automation/calendar/Calendar.tsx b/csm_web/frontend/src/components/enrollment_automation/calendar/Calendar.tsx index 706de208..e07785bb 100644 --- a/csm_web/frontend/src/components/enrollment_automation/calendar/Calendar.tsx +++ b/csm_web/frontend/src/components/enrollment_automation/calendar/Calendar.tsx @@ -1,22 +1,21 @@ +import { DateTime, Duration, Interval } from "luxon"; import React, { useEffect, useMemo, useRef, useState } from "react"; -import { formatTime } from "../utils"; +import { DATETIME_INITIAL_INVALID, formatTime, INTERVAL_INITIAL_INVALID } from "../../../utils/datetime"; + +import { Time } from "../EnrollmentAutomationTypes"; import { CalendarDay, CalendarDayHeader } from "./CalendarDay"; import { CalendarEvent, CalendarEventSingleTime, DAYS } from "./CalendarTypes"; // default start and end times for calendar -const START = 8 * 60 + 0; // 8:00 AM -const END = 18 * 60 + 0; // 6:00 PM -const INTERVAL_LENGTH = 30; -const SCROLL_AMT = 30; +const START = DateTime.fromObject({ hour: 8, minute: 0 }); // 8:00 AM +const END = DateTime.fromObject({ hour: 18, minute: 0 }); // 6:00 PM +const INTERVAL_LENGTH = Duration.fromObject({ minutes: 30 }); +const SCROLL_AMT = Duration.fromObject({ minutes: 30 }); -const WIDTH_SCALE = 0.9; +const MIN = DateTime.fromObject({ hour: 0, minute: 0 }); +const MAX = DateTime.fromObject({ hour: 24, minute: 0 }); -interface Time { - day: string; - startTime: number; - endTime: number; - isLinked: boolean; // whether this time is linked to another within a section -} +const WIDTH_SCALE = 0.9; interface CalendarProps { events: CalendarEvent[]; @@ -52,38 +51,64 @@ export function Calendar({ brighterLinkedTimes, flushDetails }: CalendarProps): React.ReactElement { - const [viewBounds, setViewBounds] = useState<{ start: number; end: number }>({ start: START, end: END }); + /** + * User viewing bounds of the calendar events. + */ + const [viewBounds, setViewBounds] = useState(Interval.fromDateTimes(START, END)); + /** Height of an interval in pixels. */ const [intervalHeight, setIntervalHeight] = useState(0); + /** Width of an interval in pixels; takes `WIDTH_SCALE` into account. */ const [intervalWidth, setIntervalWidth] = useState(0); + /** Index of the current event the user is hovering over. */ const [eventHoverIndex, setEventHoverIndex] = useState(-1); + /** + * Whether the user is currently creating an event. + */ const [creatingEvent, setCreatingEvent] = useState(false); - const [curCreatedEvent, setCurCreatedEvent] = useState
    - + {DAYS.map((day, idx) => ( - {i % 60 == 0 ? formatTime(i) : ""} +
    + {d.get("minute") % 60 == 0 ? formatTime(d) : ""}
    ); } diff --git a/csm_web/frontend/src/components/enrollment_automation/calendar/CalendarDay.tsx b/csm_web/frontend/src/components/enrollment_automation/calendar/CalendarDay.tsx index f090a42b..27a903a5 100644 --- a/csm_web/frontend/src/components/enrollment_automation/calendar/CalendarDay.tsx +++ b/csm_web/frontend/src/components/enrollment_automation/calendar/CalendarDay.tsx @@ -1,16 +1,12 @@ +import { Duration, Interval } from "luxon"; import React, { useEffect, useState } from "react"; -import { formatInterval } from "../utils"; + +import { Time } from "../EnrollmentAutomationTypes"; import { CalendarEventSingleTime } from "./CalendarTypes"; +import { formatInterval } from "../../../utils/datetime"; const NO_CONTENT_WIDTH = 35; -interface NumberTime { - day: string; - startTime: number; - endTime: number; - isLinked: boolean; -} - enum EventType { SAVED, CREATED, @@ -26,8 +22,7 @@ interface ComputedTime { // day/start/end attributes from the event day: string; - startTime: number; - endTime: number; + interval: Interval; // associated event index and event event_idx?: number; @@ -41,9 +36,8 @@ interface ComputedTime { interface CalendarDayProps { day: string; - startTime: number; - endTime: number; - intervalLength: number; + interval: Interval; + intervalLength: Duration; eventHoverIndex: number; eventSelectIndices: number[]; events: { @@ -51,34 +45,23 @@ interface CalendarDayProps { event: CalendarEventSingleTime; isLinked: boolean; }[]; - curCreatedTimes: NumberTime[]; - curCreatedTime: NumberTime; + curCreatedTimes: Time[]; + curCreatedTime: Time; intervalHeight: number; intervalWidth: number; onEventClick: (index: number, add: boolean) => void; onEventHover: (index: number) => void; - onCreateDragStart: (day: string, startTime: number, endTime: number) => void; - onCreateDragOver: (day: string, startTime: number, endTime: number) => void; - onCreateDragEnd: (day: string, startTime: number, endTime: number) => void; + onCreateDragStart: (day: string, interval: Interval) => void; + onCreateDragOver: (day: string, interval: Interval) => void; + onCreateDragEnd: (day: string, interval: Interval) => void; getEventDetails: (event: CalendarEventSingleTime) => React.ReactElement; brighterLinkedTimes: boolean; flushDetails: boolean; } -/** - * Parses a 24h time string and returns the number of minutes past midnight. - * @param time 24h time string - * @returns minutes past midnight - */ -function parseTime(time: string): number { - const [hours, minutes] = time.split(":"); - return parseInt(hours) * 60 + parseInt(minutes); -} - export function CalendarDay({ day, - startTime, - endTime, + interval, intervalLength, eventHoverIndex, eventSelectIndices: eventSelectIndex, @@ -108,8 +91,7 @@ export function CalendarDay({ ({ event_idx, event, isLinked }): ComputedTime => ({ eventType: EventType.SAVED, day: event.time.day, - startTime: event.time.startTime, - endTime: event.time.endTime, + interval: event.time.interval, event_idx, event, isLinked, @@ -122,8 +104,7 @@ export function CalendarDay({ filteredCurCreatedTimes.map((time, idx) => ({ eventType: EventType.CREATED, day: time.day, - startTime: time.startTime, - endTime: time.endTime, + interval: time.interval, event_idx: idx, isLinked: time.isLinked, track: -1, @@ -135,8 +116,7 @@ export function CalendarDay({ { eventType: EventType.EDITING, day: day, - startTime: curCreatedTime.startTime, - endTime: curCreatedTime.endTime, + interval: curCreatedTime.interval, isLinked: curCreatedTime.isLinked, track: -1, totalTracks: -1 @@ -155,8 +135,10 @@ export function CalendarDay({ const eventElements = []; const getEventPosition = (computedTime: ComputedTime) => { - const eventYOffset = (intervalHeight * (computedTime.startTime - startTime)) / intervalLength; - const eventHeight = (intervalHeight * (computedTime.endTime - computedTime.startTime)) / intervalLength; + const durationToComputedStart = computedTime.interval.start!.diff(interval.start!); + const durationToComputedEnd = computedTime.interval.end!.diff(computedTime.interval.start!); + const eventYOffset = intervalHeight * (durationToComputedStart.as("seconds") / intervalLength.as("seconds")); + const eventHeight = intervalHeight * (durationToComputedEnd.as("seconds") / intervalLength.as("seconds")); const eventWidth = intervalWidth / computedTime.totalTracks; const eventXOffset = eventWidth * computedTime.track; @@ -198,10 +180,7 @@ export function CalendarDay({ } return ( -
    +
    { const eventPosition = getEventPosition(computedTime); return ( -
    +
    - {eventPosition.showContent ? formatInterval(computedTime.startTime, computedTime.endTime) : "..."} + {eventPosition.showContent ? formatInterval(computedTime.interval) : "..."}
    @@ -244,7 +220,7 @@ export function CalendarDay({ return (
    - {eventPosition.showContent ? formatInterval(computedTime.startTime, computedTime.endTime) : "..."} + {eventPosition.showContent ? formatInterval(computedTime.interval) : "..."}
    @@ -274,13 +250,12 @@ export function CalendarDay({ // create array of intervals by intervalLength const intervals = []; - for (let i = startTime; i < endTime; i += intervalLength) { + for (let d = interval.start!; d < interval.end!; d = d.plus(intervalLength)) { intervals.push( void; - onCreateDragOver: (day: string, startTime: number, endTime: number) => void; - onCreateDragEnd: (day: string, startTime: number, endTime: number) => void; + interval: Interval; + onCreateDragStart: (day: string, interval: Interval) => void; + onCreateDragOver: (day: string, interval: Interval) => void; + onCreateDragEnd: (day: string, interval: Interval) => void; } function CalendarDayInterval({ day, - startTime, - endTime, + interval, onCreateDragStart, onCreateDragOver, onCreateDragEnd @@ -326,14 +299,14 @@ function CalendarDayInterval({ const dragStartWrapper = (e: React.MouseEvent) => { if (e.buttons === 1) { e.preventDefault(); - onCreateDragStart(day, startTime, endTime); + onCreateDragStart(day, interval); } }; const dragOverWrapper = (e: React.MouseEvent) => { if (e.buttons === 1) { e.preventDefault(); - onCreateDragOver(day, startTime, endTime); + onCreateDragOver(day, interval); } }; @@ -341,7 +314,7 @@ function CalendarDayInterval({ // stop event propagation to capture this as a true drag end e.preventDefault(); e.stopPropagation(); - onCreateDragEnd(day, startTime, endTime); + onCreateDragEnd(day, interval); }; return ( @@ -375,13 +348,13 @@ const computeLocations = (computedTimes: ComputedTime[]): ComputedTime[] => { const sharedState = {}; markers.push({ markerType: MarkerType.START, - time: computedTime.startTime, + time: computedTime.interval.start!.toMillis(), computedTime, state: sharedState }); markers.push({ markerType: MarkerType.END, - time: computedTime.endTime, + time: computedTime.interval.end!.toMillis(), computedTime, state: sharedState }); diff --git a/csm_web/frontend/src/components/enrollment_automation/coordinator/ConfigureStage.tsx b/csm_web/frontend/src/components/enrollment_automation/coordinator/ConfigureStage.tsx index 42b1e305..71a05a21 100644 --- a/csm_web/frontend/src/components/enrollment_automation/coordinator/ConfigureStage.tsx +++ b/csm_web/frontend/src/components/enrollment_automation/coordinator/ConfigureStage.tsx @@ -6,7 +6,7 @@ import LoadingSpinner from "../../LoadingSpinner"; import { Calendar } from "../calendar/Calendar"; import { CalendarEventSingleTime } from "../calendar/CalendarTypes"; import { Slot } from "../EnrollmentAutomationTypes"; -import { formatInterval } from "../utils"; +import { formatInterval } from "../../../utils/datetime"; import CheckCircle from "../../../../static/frontend/img/check_circle.svg"; import ErrorCircle from "../../../../static/frontend/img/error_outline.svg"; @@ -82,7 +82,7 @@ export const ConfigureStage = ({ profile, slots, recomputeStage }: ConfigureStag const getEventDetails = (event: CalendarEventSingleTime) => { return ( - {formatInterval(event.time.startTime, event.time.endTime)} + {formatInterval(event.time.interval)}
    ({minMentorMap.get(event.id)}–{maxMentorMap.get(event.id)}) diff --git a/csm_web/frontend/src/components/enrollment_automation/coordinator/CoordinatorMatcherForm.tsx b/csm_web/frontend/src/components/enrollment_automation/coordinator/CoordinatorMatcherForm.tsx index a4c0bb4e..62f9cfad 100644 --- a/csm_web/frontend/src/components/enrollment_automation/coordinator/CoordinatorMatcherForm.tsx +++ b/csm_web/frontend/src/components/enrollment_automation/coordinator/CoordinatorMatcherForm.tsx @@ -1,7 +1,8 @@ +import { Interval } from "luxon"; import React, { useEffect, useState } from "react"; + import { Profile } from "../../../utils/types"; import { parseTime } from "../utils"; - import { useMatcherAssignment, useMatcherConfig, @@ -63,8 +64,7 @@ export function CoordinatorMatcherForm({ const new_slots: Slot[] = jsonSlots.slots.map((slot: { id: number; times: StrTime[] }) => { const new_times: Time[] = slot.times.map((time: StrTime) => { return { - startTime: parseTime(time.startTime), - endTime: parseTime(time.endTime), + interval: Interval.fromDateTimes(parseTime(time.startTime), parseTime(time.endTime)), day: time.day, isLinked: slot.times.length > 0 }; diff --git a/csm_web/frontend/src/components/enrollment_automation/coordinator/CreateStage.tsx b/csm_web/frontend/src/components/enrollment_automation/coordinator/CreateStage.tsx index d5809c7e..6e2542f2 100644 --- a/csm_web/frontend/src/components/enrollment_automation/coordinator/CreateStage.tsx +++ b/csm_web/frontend/src/components/enrollment_automation/coordinator/CreateStage.tsx @@ -1,4 +1,5 @@ import _ from "lodash"; +import { DateTime, Duration, Interval } from "luxon"; import React, { useEffect, useState } from "react"; import { Profile } from "../../../utils/types"; @@ -6,18 +7,19 @@ import { Tooltip } from "../../Tooltip"; import { Calendar } from "../calendar/Calendar"; import { CalendarEvent, CalendarEventSingleTime, DAYS, DAYS_ABBREV } from "../calendar/CalendarTypes"; import { Slot, Time } from "../EnrollmentAutomationTypes"; -import { formatInterval, formatTime, parseTime, serializeTime } from "../utils"; +import { parseTime, serializeTime } from "../utils"; +import { useMatcherConfigMutation, useMatcherSlotsMutation } from "../../../utils/queries/matcher"; +import { formatInterval } from "../../../utils/datetime"; import InfoIcon from "../../../../static/frontend/img/info.svg"; import XIcon from "../../../../static/frontend/img/x.svg"; -import { useMatcherConfigMutation, useMatcherSlotsMutation } from "../../../utils/queries/matcher"; interface TileDetails { days: string[]; daysLinked: boolean; - startTime: number; - endTime: number; - length: number; + start: DateTime; + end: DateTime; + length: Duration; } interface CreateStageProps { @@ -59,9 +61,9 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro const [tileDetails, setTileDetails] = useState({ days: [], daysLinked: true, - startTime: -1, - endTime: -1, - length: 60 + start: DateTime.invalid("initial value"), + end: DateTime.invalid("initial value"), + length: Duration.fromObject({ hours: 1 }) }); /** @@ -100,19 +102,14 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro useEffect(() => { // update calendar with tiled events - if ( - tileDetails.startTime !== -1 && - tileDetails.endTime !== -1 && - tileDetails.length !== -1 && - tileDetails.startTime < tileDetails.endTime - ) { + const interval = Interval.fromDateTimes(tileDetails.start, tileDetails.end); + if (interval.isValid && tileDetails.length.isValid) { const newTimes: Time[] = []; - for (let t = tileDetails.startTime; t <= tileDetails.endTime - tileDetails.length; t += tileDetails.length) { + for (let t = interval.start!; t <= interval.end!.minus(tileDetails.length); t = t.plus(tileDetails.length)) { for (const day of tileDetails.days) { newTimes.push({ day: day, - startTime: t, - endTime: t + tileDetails.length, + interval: Interval.fromDateTimes(t, t.plus(tileDetails.length)), // linked only if there are multiple days and user wants to link them isLinked: tileDetails.daysLinked && tileDetails.days.length > 1 }); @@ -129,8 +126,8 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro const converted_slots = slots.map(slot => { const times = slot.times.map(time => ({ day: time.day, - startTime: serializeTime(time.startTime), - endTime: serializeTime(time.endTime) + startTime: serializeTime(time.interval.start!), + endTime: serializeTime(time.interval.end!) })); return { ...slot, @@ -163,7 +160,7 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro /** * Update current event with a new related time * - * @param time new time to add + * @param time - new time to add */ const updateTimes = (time: Time): void => { if (creatingTiledEvents) { @@ -179,15 +176,20 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro } } if (tileRefs.startTime.current) { - tileRefs.startTime.current.value = serializeTime(time.startTime); + tileRefs.startTime.current.value = serializeTime(time.interval.start!); } if (tileRefs.endTime.current) { - tileRefs.endTime.current.value = serializeTime(time.endTime); + tileRefs.endTime.current.value = serializeTime(time.interval.end!); } if (tileRefs.length.current) { - tileRefs.length.current.value = tileDetails.length.toString(); + tileRefs.length.current.value = tileDetails.length.as("minutes").toString(); } - setTileDetails({ ...tileDetails, days: [time.day], startTime: time.startTime, endTime: time.endTime }); + setTileDetails({ + ...tileDetails, + days: [time.day], + start: time.interval.start ?? DateTime.invalid("initial value"), + end: time.interval.end ?? DateTime.invalid("initial value") + }); } else { const newTimes = [...curCreatedTimes, time]; setCurCreatedTimes(newTimes); @@ -197,8 +199,8 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro /** * Delete a time from current event * - * @param index index of time to remove - * @param useSelected whether to use selected event or the event currently being created + * @param index - index of time to remove + * @param useSelected - whether to use selected event or the event currently being created */ const deleteTime = (index: number) => { const newTimes = [...curCreatedTimes]; @@ -215,9 +217,9 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro /** * Edit the day field of an event * - * @param index index of time to edit - * @param newDay new day value for time - * @param useSelected whether to use selected event or the event currently being created + * @param index - index of time to edit + * @param newDay - new day value for time + * @param useSelected - whether to use selected event or the event currently being created */ const editTime_day = (index: number, newDay: string): void => { if (!DAYS.includes(newDay)) { @@ -231,26 +233,26 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro /** * Edit the start time field of an event * - * @param index index of time to edit - * @param newStartTime new start time value - * @param useSelected whether to use selected event or the event currently being created + * @param index - index of time to edit + * @param newStartTime - new start time value + * @param useSelected - whether to use selected event or the event currently being created */ const editTime_startTime = (index: number, newStartTime: string) => { const newTimes = [...curCreatedTimes]; - newTimes[index]["startTime"] = parseTime(newStartTime); + newTimes[index].interval = Interval.fromDateTimes(parseTime(newStartTime), newTimes[index].interval.end!); setCurCreatedTimes(newTimes); }; /** * Edit the end time field of an event * - * @param index index of time to edit - * @param newEndTime new end time value - * @param useSelected whether to use selected event or the event currently being created + * @param index - index of time to edit + * @param newEndTime - new end time value + * @param useSelected - whether to use selected event or the event currently being created */ const editTime_endTime = (index: number, newEndTime: string) => { const newTimes = [...curCreatedTimes]; - newTimes[index]["endTime"] = parseTime(newEndTime); + newTimes[index].interval = Interval.fromDateTimes(newTimes[index].interval.start!, parseTime(newEndTime)); setCurCreatedTimes(newTimes); }; @@ -265,8 +267,8 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro ...tileDetails, days: [], daysLinked: true, - startTime: -1, - endTime: -1 + start: DateTime.invalid("initial value"), + end: DateTime.invalid("initial value") }); } else { setCurCreatedTimes([]); @@ -274,11 +276,31 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro setCreatingTiledEvents(checked); }; - const editTiled_number = (field: string, value: number): void => { - if (isNaN(value)) { + const editTiled_interval = (endpoint: "start" | "end", datetime: DateTime): void => { + let newDetails = null; + if (endpoint === "start") { + newDetails = { + ...tileDetails, + start: datetime + }; + } else if (endpoint === "end") { + newDetails = { + ...tileDetails, + end: datetime + }; + } + + if (newDetails != null) { + setTileDetails(newDetails); + } + }; + + const editTiled_length = (duration: number): void => { + if (isNaN(duration)) { return; } - const newDetails = { ...tileDetails, [field]: value }; + + const newDetails = { ...tileDetails, length: Duration.fromObject({ minutes: duration }) }; setTileDetails(newDetails); }; @@ -299,21 +321,22 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro const saveTiledEvents = () => { const newSlots = []; - for (let t = tileDetails.startTime; t <= tileDetails.endTime - tileDetails.length; t += tileDetails.length) { + for (let t = tileDetails.start; t <= tileDetails.end.minus(tileDetails.length); t = t.plus(tileDetails.length)) { if (tileDetails.daysLinked) { const newEvent: CalendarEvent = { times: [] }; for (const day of tileDetails.days) { newEvent.times.push({ day: day, - startTime: t, - endTime: t + tileDetails.length, + interval: Interval.fromDateTimes(t, t.plus(tileDetails.length)), isLinked: tileDetails.days.length > 1 }); } newSlots.push(newEvent); } else { for (const day of tileDetails.days) { - newSlots.push({ times: [{ day: day, startTime: t, endTime: t + tileDetails.length, isLinked: false }] }); + newSlots.push({ + times: [{ day: day, interval: Interval.fromDateTimes(t, t.plus(tileDetails.length)), isLinked: false }] + }); } } } @@ -402,7 +425,7 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro const getEventDetails = (event: CalendarEventSingleTime) => { return ( - {formatInterval(event.time.startTime, event.time.endTime)} + {formatInterval(event.time.interval)} {/*
    Num. Mentors: {event.num_mentors} */}
    @@ -446,17 +469,17 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro editTiled_number("startTime", parseTime(e.target.value))} + onChange={e => editTiled_interval("start", parseTime(e.target.value))} /> – editTiled_number("endTime", parseTime(e.target.value))} + onChange={e => editTiled_interval("end", parseTime(e.target.value))} />
    @@ -467,8 +490,8 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro ref={tileRefs.length} min={15} step={5} - defaultValue={tileDetails.length} - onChange={e => e.target.validity.valid && editTiled_number("length", parseInt(e.target.value))} + defaultValue={tileDetails.length.as("minutes")} + onChange={e => e.target.validity.valid && editTiled_length(parseInt(e.target.value))} /> mins
    @@ -508,7 +531,7 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro editTime_startTime(time_idx, e.target.value)} /> @@ -516,7 +539,7 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro editTime_endTime(time_idx, e.target.value)} /> @@ -556,7 +579,7 @@ export function CreateStage({ profile, initialSlots, nextStage }: CreateStagePro {selectedEvents[0].times.map((time, time_idx) => (
  • - {time.day} {formatTime(time.startTime)}–{formatTime(time.endTime)} + {time.day} {formatInterval(time.interval)}
  • ))} diff --git a/csm_web/frontend/src/components/enrollment_automation/coordinator/EditStage.tsx b/csm_web/frontend/src/components/enrollment_automation/coordinator/EditStage.tsx index 418e9ffc..401d1610 100644 --- a/csm_web/frontend/src/components/enrollment_automation/coordinator/EditStage.tsx +++ b/csm_web/frontend/src/components/enrollment_automation/coordinator/EditStage.tsx @@ -1,3 +1,4 @@ +import { Interval } from "luxon"; import React, { useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; @@ -13,7 +14,7 @@ import { Tooltip } from "../../Tooltip"; import { Calendar } from "../calendar/Calendar"; import { CalendarEventSingleTime, DAYS, DAYS_ABBREV } from "../calendar/CalendarTypes"; import { Assignment, Slot, SlotPreference, Time } from "../EnrollmentAutomationTypes"; -import { formatInterval, formatTime } from "../utils"; +import { formatInterval } from "../../../utils/datetime"; import ErrorIcon from "../../../../static/frontend/img/error_outline.svg"; import InfoIcon from "../../../../static/frontend/img/info.svg"; @@ -49,9 +50,17 @@ const SORT_FUNCTIONS: Record number> = { if (Math.min(...aDays) != Math.min(...bDays)) { return Math.min(...aDays) - Math.min(...bDays); } else { - const aTimes = a.map(t => t.startTime); - const bTimes = b.map(t => t.startTime); - return Math.min(...aTimes) - Math.min(...bTimes); + const mergeIntervals = (merged: Interval | null, current: Time) => { + if (merged == null) { + return current.interval; + } else { + return merged.union(current.interval); + } + }; + + const mergedATimes = a.reduce(mergeIntervals, null); + const mergedBTimes = b.reduce(mergeIntervals, null); + return mergedATimes!.start!.toMillis() - mergedBTimes!.start!.toMillis(); } } }; @@ -703,7 +712,7 @@ const EditTableRow = ({ * Format a datetime as a string for display. */ const displayTime = (time: Time) => { - return `${DAYS_ABBREV[time.day]} ${formatTime(time.startTime)}\u2013${formatTime(time.endTime)}`; + return `${DAYS_ABBREV[time.day]} ${formatInterval(time.interval)}`; }; /** @@ -871,9 +880,7 @@ const AssignmentDistributionModal = ({ placement="bottom" source={
    - - {formatInterval(event.time.startTime, event.time.endTime)} - + {formatInterval(event.time.interval)}
    } > diff --git a/csm_web/frontend/src/components/enrollment_automation/coordinator/ReleaseStage.tsx b/csm_web/frontend/src/components/enrollment_automation/coordinator/ReleaseStage.tsx index e1b9d5b5..c6f9ef8b 100644 --- a/csm_web/frontend/src/components/enrollment_automation/coordinator/ReleaseStage.tsx +++ b/csm_web/frontend/src/components/enrollment_automation/coordinator/ReleaseStage.tsx @@ -14,7 +14,7 @@ import { Tooltip } from "../../Tooltip"; import { Calendar } from "../calendar/Calendar"; import { CalendarEventSingleTime } from "../calendar/CalendarTypes"; import { Slot, SlotPreference } from "../EnrollmentAutomationTypes"; -import { formatInterval } from "../utils"; +import { formatInterval } from "../../../utils/datetime"; import CheckIcon from "../../../../static/frontend/img/check.svg"; import CheckCircleIcon from "../../../../static/frontend/img/check_circle.svg"; @@ -604,9 +604,7 @@ function PreferenceStatusModal({ placement="top" source={
    - - {formatInterval(event.time.startTime, event.time.endTime)} - + {formatInterval(event.time.interval)}
    } > diff --git a/csm_web/frontend/src/components/enrollment_automation/utils.tsx b/csm_web/frontend/src/components/enrollment_automation/utils.tsx index 9f9475df..4f2dce67 100644 --- a/csm_web/frontend/src/components/enrollment_automation/utils.tsx +++ b/csm_web/frontend/src/components/enrollment_automation/utils.tsx @@ -1,65 +1,25 @@ -import React from "react"; +import { DateTime } from "luxon"; /** * Maximum preference for mentors */ export const MAX_PREFERENCE = 5; -/** - * Convert 24hr time to 12hr time - * - * @param time string in format "HH:MM" - */ -export function formatTime(time: number, show_ampm = true): string { - const hours = Math.floor(time / 60); - const minutes = time % 60; - let ampm; - if (show_ampm) { - ampm = hours >= 12 ? "pm" : "am"; - } else { - ampm = ""; - } - if (minutes == 0) { - return `${hours > 12 ? hours % 12 : hours === 0 ? 12 : hours}${ampm}`; - } - return `${hours > 12 ? hours % 12 : hours === 0 ? 12 : hours}:${minutes}${ampm}`; -} - -export function formatInterval(start: number, end: number) { - const startHours = Math.floor(start / 60); - const endHours = Math.floor(end / 60); - if ((startHours >= 12 && endHours < 12) || (startHours < 12 && endHours >= 12)) { - return ( - - {formatTime(start)} – {formatTime(end)} - - ); - } else { - return ( - - {formatTime(start, false)} – {formatTime(end)} - - ); - } -} - /** * Serialize time into HH:MM format * * @param time number of minutes past midnight */ -export function serializeTime(time: number): string { - const hours = Math.floor(time / 60); - const minutes = time % 60; - return `${hours < 10 ? "0" : ""}${hours}:${minutes < 10 ? "0" : ""}${minutes}`; +export function serializeTime(time: DateTime): string { + return time.toFormat("HH:mm")!; } /** - * Parses a 24h time string and returns the number of minutes past midnight. + * Parses a 24h time string into a DateTime object + * * @param time 24h time string - * @returns minutes past midnight + * @returns datetime object */ -export function parseTime(time: string): number { - const [hours, minutes] = time.split(":"); - return parseInt(hours) * 60 + parseInt(minutes); +export function parseTime(time: string): DateTime { + return DateTime.fromFormat(time, "HH:mm"); } diff --git a/csm_web/frontend/src/components/resource_aggregation/ResourceRowRender.tsx b/csm_web/frontend/src/components/resource_aggregation/ResourceRowRender.tsx index ef1d63a7..7d652a77 100644 --- a/csm_web/frontend/src/components/resource_aggregation/ResourceRowRender.tsx +++ b/csm_web/frontend/src/components/resource_aggregation/ResourceRowRender.tsx @@ -4,6 +4,8 @@ import { Resource } from "./ResourceTypes"; import Pencil from "../../../static/frontend/img/pencil.svg"; import Trash from "../../../static/frontend/img/trash-alt.svg"; +import { formatDate } from "../../utils/datetime"; +import { DateTime } from "luxon"; interface ResourceRowRenderProps { resource: Resource; @@ -58,7 +60,7 @@ const ResourceRowRender = ({ resource, canEdit, onSetEdit, onDelete }: ResourceR
    Week {resource.weekNum}
    -
    {resource.date}
    +
    {formatDate(DateTime.fromISO(resource.date))}
    diff --git a/csm_web/frontend/src/components/resource_aggregation/ResourceTable.tsx b/csm_web/frontend/src/components/resource_aggregation/ResourceTable.tsx index e49667f6..1307ab73 100644 --- a/csm_web/frontend/src/components/resource_aggregation/ResourceTable.tsx +++ b/csm_web/frontend/src/components/resource_aggregation/ResourceTable.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from "react"; +import { DateTime, Duration } from "luxon"; import { useCreateResourceMutation, @@ -12,6 +13,7 @@ import ResourceRow from "./ResourceRow"; import { emptyResource, Link, Resource, Worksheet } from "./ResourceTypes"; import PlusCircle from "../../../static/frontend/img/plus-circle.svg"; +import { DEFAULT_TIMEZONE } from "../../utils/datetime"; interface ResourceTableProps { courseID: number; @@ -226,14 +228,11 @@ export const ResourceTable = ({ courseID, roles }: ResourceTableProps): React.Re const lastResource = resources[resources.length - 1]; // get last resource newResource.weekNum = lastResource.weekNum + 1; - // add a week - const date = new Date(Date.parse(lastResource.date)); - date.setUTCDate(date.getUTCDate() + 7); + // parse date from the resource, and add a week + const date = DateTime.fromISO(lastResource.date, { zone: DEFAULT_TIMEZONE }).plus(Duration.fromObject({ week: 1 })); - // pad month and day - const newMonth = `${date.getUTCMonth() + 1}`.padStart(2, "0"); - const newDay = `${date.getUTCDate()}`.padStart(2, "0"); - newResource.date = `${date.getUTCFullYear()}-${newMonth}-${newDay}`; + // format the new date + newResource.date = date.toISODate()!; return newResource; } diff --git a/csm_web/frontend/src/components/section/CoordinatorAddStudentModal.tsx b/csm_web/frontend/src/components/section/CoordinatorAddStudentModal.tsx index 04305bad..7f6380ae 100644 --- a/csm_web/frontend/src/components/section/CoordinatorAddStudentModal.tsx +++ b/csm_web/frontend/src/components/section/CoordinatorAddStudentModal.tsx @@ -7,6 +7,7 @@ import Modal from "../Modal"; import CheckCircle from "../../../static/frontend/img/check_circle.svg"; import ErrorCircle from "../../../static/frontend/img/error_outline.svg"; import { useEnrollStudentMutation } from "../../utils/queries/sections"; +import { DateTime } from "luxon"; enum CoordModalStates { INITIAL = "INITIAL", @@ -502,7 +503,7 @@ export function CoordinatorAddStudentModal({

    Internal Error

    {response && response.errors && response.errors.critical}
    -
    Timestamp: {`${new Date()}`}
    +
    Timestamp: {`${DateTime.now().toISO()}`}
    ); diff --git a/csm_web/frontend/src/components/section/MentorSectionAttendance.tsx b/csm_web/frontend/src/components/section/MentorSectionAttendance.tsx index d01d4106..594c9177 100644 --- a/csm_web/frontend/src/components/section/MentorSectionAttendance.tsx +++ b/csm_web/frontend/src/components/section/MentorSectionAttendance.tsx @@ -7,7 +7,7 @@ import { } from "../../utils/queries/sections"; import LoadingSpinner from "../LoadingSpinner"; import { ATTENDANCE_LABELS } from "./Section"; -import { dateSortISO, formatDateISO } from "./utils"; +import { dateSortISO, formatDateLocaleShort } from "./utils"; import { Attendance } from "../../utils/types"; import randomWords from "random-words"; @@ -256,7 +256,7 @@ const MentorSectionAttendance = ({ sectionId }: MentorSectionAttendanceProps): R className={id === selectedOccurrence!.id ? "active" : ""} onClick={() => handleSelectOccurrence({ id, date })} > - {formatDateISO(date)} + {formatDateLocaleShort(date)}
    ))}
    @@ -299,7 +299,7 @@ const MentorSectionAttendance = ({ sectionId }: MentorSectionAttendanceProps): R

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

    {wotdLoaded ? ( diff --git a/csm_web/frontend/src/components/section/SpacetimeEditModal.tsx b/csm_web/frontend/src/components/section/SpacetimeEditModal.tsx index b28c50c1..bbebee6f 100644 --- a/csm_web/frontend/src/components/section/SpacetimeEditModal.tsx +++ b/csm_web/frontend/src/components/section/SpacetimeEditModal.tsx @@ -1,10 +1,11 @@ +import { DateTime } from "luxon"; import React, { useState } from "react"; import { useSpacetimeModifyMutation, useSpacetimeOverrideMutation } from "../../utils/queries/spacetime"; import { Spacetime } from "../../utils/types"; import LoadingSpinner from "../LoadingSpinner"; import Modal from "../Modal"; import TimeInput from "../TimeInput"; -import { DAYS_OF_WEEK, zeroPadTwoDigit } from "./utils"; +import { DAYS_OF_WEEK } from "./utils"; interface SpacetimeEditModalProps { sectionId: number; @@ -49,8 +50,7 @@ const SpaceTimeEditModal = ({ closeModal(); }; - const now = new Date(); - const today = `${now.getFullYear()}-${zeroPadTwoDigit(now.getMonth() + 1)}-${zeroPadTwoDigit(now.getDate())}`; + const today = DateTime.now().toISODate()!; return ( diff --git a/csm_web/frontend/src/components/section/StudentSection.tsx b/csm_web/frontend/src/components/section/StudentSection.tsx index 09229c32..54ba9ea7 100644 --- a/csm_web/frontend/src/components/section/StudentSection.tsx +++ b/csm_web/frontend/src/components/section/StudentSection.tsx @@ -8,11 +8,13 @@ import { import { Mentor, Override, Spacetime } from "../../utils/types"; import Modal from "../Modal"; import { ATTENDANCE_LABELS, InfoCard, ROLES, SectionDetail, SectionSpacetime } from "./Section"; -import { dateSortWord, formatDateISOToWord } from "./utils"; +import { dateSortISO, formatDateLocaleShort, formatDateAbbrevWord } from "./utils"; import XIcon from "../../../static/frontend/img/x.svg"; import LoadingSpinner from "../LoadingSpinner"; import CheckCircle from "../../../static/frontend/img/check_circle.svg"; +import { DateTime } from "luxon"; +import { DEFAULT_TIMEZONE } from "../../utils/datetime"; interface StudentSectionType { id: number; @@ -180,7 +182,7 @@ function StudentSectionAttendance({ associatedProfileId, id }: StudentSectionAtt // only allow choosing from dates with blank attendances .filter(attendance => attendance.presence === "") // sort and get the first attendance - .sort((a, b) => dateSortWord(a.date, b.date))[0]; + .sort((a, b) => dateSortISO(a.date, b.date))[0]; setSelectedAttendanceId(firstAttendance?.id ?? -1); } }, [attendancesLoaded]); @@ -234,49 +236,12 @@ function StudentSectionAttendance({ associatedProfileId, id }: StudentSectionAtt // only compare current date if deadline exists if (wordOfTheDayDeadline !== null) { - /* - * TODO: replace all of this with a datetime library with better timezone support. - * - * There's a few things to consider here when comparing these dates. - * Firstly, the date stored in the database and all comparisons done in Django - * are in the `America/Los_Angeles` timezone, whereas JS Date objects internally use UTC, - * and without a timezone specification, it uses the local timezone of the host. - * This inconsistency means that it's hard to make the frontend exactly match - * the actual comparison done by Django in the backend. - * A compromise is to make the frontend a little bit less strict, - * allowing for submissions that match the *local* date, which would then possibly - * get rejected by the backend when it parses the request. - * The only times where the frontend would incorrectly reject inputs would be - * if anybody was west of the `America/Los_Angeles` timezone, which should be quite rare. - * - * In implementation, we first convert the deadline from the database as if it was in UTC, - * and fetch the current time via native JS Date objects. - * We then format this date in the local timezone, and trick JS into thinking - * that the displayed date is actually in UTC, and parse it again. - * Now that the date objects are stored in the same relative offset - * (both in UTC, even though they originated from different timezones), - * we can do a simple comparison without worrying about what the values actually are - * (the stored values would not actually be correct in any way, - * but would match relative to each other). - */ - - // convert from ISO yyyy-mm-dd using UTC (stored in the database as `America/Los_Angeles`) - const parsedDeadline = new Date(wordOfTheDayDeadline + "T00:00:00.000Z"); - // get the current time (stored internally as UTC) - const now = new Date(); - // convert to mm/dd/yyyy form, in the local timezone - const nowString = now.toLocaleDateString("en-US", { - year: "numeric", - month: "2-digit", - day: "2-digit" - }); - // extract each part as string - const [month, day, year] = nowString.split("/"); - // put them back in ISO format as if it was UTC - const parsedNowString = new Date(`${year}-${month}-${day}T00:00:00.000Z`); - // both are stored in UTC, even though they originated from different timezones, - // so we can now compare the two - wordOfTheDayDeadlinePassed = parsedNowString > parsedDeadline; + // convert from ISO yyyy-mm-dd (stored in the database as `America/Los_Angeles`) + const parsedDeadline = DateTime.fromISO(wordOfTheDayDeadline, { zone: DEFAULT_TIMEZONE }); + // get the current local time + const now = DateTime.now(); + // compare the two dates + wordOfTheDayDeadlinePassed = now > parsedDeadline; } } @@ -295,11 +260,11 @@ function StudentSectionAttendance({ associatedProfileId, id }: StudentSectionAtt // only allow choosing from dates with blank attendances .filter(attendance => attendance.presence === "") // sort by date (most recent first) - .sort((a, b) => dateSortWord(a.date, b.date)) + .sort((a, b) => dateSortISO(a.date, b.date)) // convert to option elements .map((occurrence, idx) => ( ))} @@ -337,7 +302,7 @@ function StudentSectionAttendance({ associatedProfileId, id }: StudentSectionAtt Deadline:{" "} - {formatDateISOToWord(wordOfTheDayDeadline)} + {formatDateAbbrevWord(wordOfTheDayDeadline)} )} @@ -355,13 +320,13 @@ function StudentSectionAttendance({ associatedProfileId, id }: StudentSectionAtt {attendances // sort by date (most recent first) - .sort((a, b) => dateSortWord(a.date, b.date)) + .sort((a, b) => dateSortISO(a.date, b.date)) // convert to a table row .map(({ presence, date }) => { const [label, cssSuffix] = ATTENDANCE_LABELS[presence]; return ( - {date} + {formatDateLocaleShort(date)}
    {label}
    diff --git a/csm_web/frontend/src/components/section/utils.tsx b/csm_web/frontend/src/components/section/utils.tsx index 1e85c07f..442065d5 100644 --- a/csm_web/frontend/src/components/section/utils.tsx +++ b/csm_web/frontend/src/components/section/utils.tsx @@ -1,3 +1,5 @@ +import { DateTime } from "luxon"; + export const MONTH_NUMBERS: Readonly<{ [month: string]: number }> = Object.freeze({ Jan: 1, Feb: 2, @@ -24,81 +26,34 @@ export const DAYS_OF_WEEK: Readonly = Object.freeze([ ]); /** - * Convert date of form yyyy-mm-dd to mm/dd. - * - * Example: - * formatDate("2022-01-06") --> "1/6" - */ -export function formatDateISO(dateString: string): string { - const [, /* ignore year */ month, day] = dateString.split("-").map(part => parseInt(part)); - return `${month}/${day}`; -} - -/** - * Convert date of form Month. day, year to mm/dd. + * Convert date from ISO to mm/dd/yy. * * Example: - * formatDate("Jan. 6, 2022") --> "1/6" + * formatDate("2022-01-06") --> "1/6/22" */ -export function formatDateWord(dateString: string): string { - const [month, dayAndYear] = dateString.split("."); - const day = dayAndYear.split(",")[0].trim(); - return `${MONTH_NUMBERS[month]}/${day}`; +export function formatDateLocaleShort(dateString: string): string { + return DateTime.fromISO(dateString).toLocaleString({ + ...DateTime.DATE_SHORT, + // use 2-digit year + year: "2-digit" + }); } /** - * Convert date of form yyyy-mm-dd to MOnth. day, year + * Convert date from ISO to "Month day, year" * * Example: - * formatDate("2022-01-06") --> "Jan. 6, 2022" + * formatDate("2022-01-06") --> "Jan 6, 2022" */ -export function formatDateISOToWord(dateString: string): string { - const [year, month, day] = dateString.split("-").map(part => parseInt(part)); - - // get word version of month - for (const [monthWord, monthNumber] of Object.entries(MONTH_NUMBERS)) { - if (monthNumber === month) { - // format with word - return `${monthWord}. ${day}, ${year}`; - } - } - return ""; +export function formatDateAbbrevWord(dateString: string): string { + return DateTime.fromISO(dateString).toLocaleString(DateTime.DATE_MED); } /** - * Conert date of form Month. day, year into mm/dd/yyyy. - * - * Example: - * formatDateYear("Jan. 6, 2022") --> 1/6/2022 - */ -function formatDateYear(dateString: string): string { - const year = dateString.split(",")[1]; - const formattedDate = formatDateWord(dateString); - return `${formattedDate}/${year.trim()}`; -} - -/** - * Sort two dates of the form yyyy-mm-dd. + * Sort two dates in ISO. */ export function dateSortISO(date1: string, date2: string) { - const [year1, month1, day1] = date1.split("-").map(part => parseInt(part)); - const [year2, month2, day2] = date2.split("-").map(part => parseInt(part)); - return year2 - year1 || month2 - month1 || day2 - day1; -} - -/** - * Sort two dates of the form Month. day, year. - * - * Example: sorting dates like Jan. 6, 2022 - */ -export function dateSortWord(date1: string, date2: string) { - const [month1, day1, year1] = formatDateYear(date1) - .split("/") - .map(part => Number(part)); - const [month2, day2, year2] = formatDateYear(date2) - .split("/") - .map(part => Number(part)); - return year2 - year1 || month2 - month1 || day2 - day1; + return DateTime.fromISO(date2).diff(DateTime.fromISO(date1)).as("milliseconds"); } export function zeroPadTwoDigit(num: number) { diff --git a/csm_web/frontend/src/utils/datetime.tsx b/csm_web/frontend/src/utils/datetime.tsx new file mode 100644 index 00000000..1d7c365e --- /dev/null +++ b/csm_web/frontend/src/utils/datetime.tsx @@ -0,0 +1,57 @@ +import { DateTime, DateTimeFormatOptions, IANAZone, Interval } from "luxon"; + +export const DEFAULT_TIMEZONE = IANAZone.create("America/Los_Angeles"); + +export const DEFAULT_LONG_LOCALE_OPTIONS: DateTimeFormatOptions = { + weekday: "long", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + hour12: true, + timeZoneName: "short" +}; + +export const DATETIME_INITIAL_INVALID = DateTime.invalid("initial value"); +export const INTERVAL_INITIAL_INVALID = Interval.invalid("initial value"); + +/** + * Format a date for display. + * + * @param datetime - datetime object to format + * @returns formatted date + */ +export const formatDate = (datetime: DateTime): string => { + return datetime.toLocaleString(DateTime.DATE_SHORT); +}; + +/** + * Format datetime as "HH:MM AM/PM". + * + * If the minutes are 0, displays "HH AM/PM" if 12-hour. + * 24-hour time display has no special handling. + * + * @param number representing the number of minutes past 12:00 AM + */ +export function formatTime(datetime: DateTime) { + if (datetime.get("minute") == 0) { + return datetime.toLocaleString({ hour: "numeric", hour12: true }); + } else { + return datetime.toLocaleString({ ...DateTime.TIME_SIMPLE, hour12: true, hour: "numeric" }); + } +} + +/** + * Format an interval object. + * + * If the minutes for both endpoints are 0, then minutes are not displayed + * (ex. 10 - 11 AM, instead of 10:00 - 11:00 AM). + * + */ +export function formatInterval(interval: Interval): string { + if (interval.start!.get("minute") != 0 || interval.end!.get("minute") != 0) { + return interval.toLocaleString({ hour: "numeric", minute: "2-digit", hour12: true }); + } else { + return interval.toLocaleString({ hour: "numeric", hour12: true }); + } +} diff --git a/csm_web/frontend/src/utils/types.tsx b/csm_web/frontend/src/utils/types.tsx index 67e1741e..15327a3c 100644 --- a/csm_web/frontend/src/utils/types.tsx +++ b/csm_web/frontend/src/utils/types.tsx @@ -1,3 +1,5 @@ +import { DateTime } from "luxon"; + export interface Override { date: string; spacetime: Spacetime; @@ -28,7 +30,7 @@ export interface UserInfo { firstName: string; lastName: string; email: string; - priorityEnrollment?: Date; + priorityEnrollment?: DateTime; } /** diff --git a/csm_web/scheduler/serializers.py b/csm_web/scheduler/serializers.py index f0a20e78..1d431c35 100644 --- a/csm_web/scheduler/serializers.py +++ b/csm_web/scheduler/serializers.py @@ -208,9 +208,7 @@ class Meta: class AttendanceSerializer(serializers.ModelSerializer): - date = serializers.DateField( - source="sectionOccurrence.date", format="%b. %-d, %Y", read_only=True - ) + date = serializers.DateField(source="sectionOccurrence.date", read_only=True) student_name = serializers.CharField(source="student.name") student_id = serializers.IntegerField(source="student.id") student_email = serializers.CharField(source="student.user.email") diff --git a/cypress/e2e/course/coordinator-course.cy.ts b/cypress/e2e/course/coordinator-course.cy.ts index f4df7c72..70ecfd3e 100644 --- a/cypress/e2e/course/coordinator-course.cy.ts +++ b/cypress/e2e/course/coordinator-course.cy.ts @@ -1,3 +1,5 @@ +import { DateTime } from "luxon"; + before(() => { // initialize the database and cache cy.initDB(); @@ -6,19 +8,8 @@ before(() => { /** * Converts a time of the form hh:mm a/pm into a Date object */ -const timeStringToDate = (time: string): Date => { - // extract hours, minutes, am/pm - const [, hours_str, minutes, ampm] = time.match(/(\d\d?):(\d\d) (AM|PM)/); - - let hours = parseInt(hours_str); - if (ampm === "PM" && hours !== 12) { - hours += 12; - } else if (ampm === "AM" && hours === 12) { - hours = 0; - } - const formatted_hours = hours.toString().padStart(2, "0"); - // put in iso format to ensure parse succeeds - return new Date(Date.parse(`2020-01-01T${formatted_hours}:${minutes}:00.000`)); +const timeStringToDate = (time: string): DateTime => { + return DateTime.fromFormat(time, "h:mm a"); }; /** @@ -37,13 +28,13 @@ const checkCapacity = (text: string, isFull = false) => { * Check that the cards are in chronological order */ const checkCardOrder = () => { - let prevTime = null; + let prevTime: DateTime = null; cy.get('[title="Time"]').each($el => { const text = $el.text().trim(); // time of form [day] [start]-[end] [AM/PM] // or of form [day] [start] [AM/PM]-[end] [AM/PM] const matches = text.match(/\w+ (\d\d?:\d\d(?: AM| PM)?)-(\d\d?:\d\d (?:A|P)M)/g); - let sectionTime = null; + let sectionTime: DateTime = null; for (const substr of matches) { // get groups in this match const match = substr.match(/\w+ (\d\d?:\d\d(?: AM| PM)?)-(\d\d?:\d\d (?:A|P)M)/); @@ -61,7 +52,7 @@ const checkCardOrder = () => { if (prevTime !== null) { // should be chronological - expect(prevTime).to.be.lte(sectionTime); + expect(prevTime.toMillis()).to.be.lte(sectionTime.toMillis()); } prevTime = sectionTime; }); diff --git a/package-lock.json b/package-lock.json index 3555d5aa..103c8e37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@types/react-dom": "^18.0.5", "js-cookie": "^2.2.1", "lodash": "^4.17.21", + "luxon": "^3.3.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.3.0" @@ -34,6 +35,7 @@ "@testing-library/react": "^13.4.0", "@types/js-cookie": "^3.0.2", "@types/lodash": "^4.14.175", + "@types/luxon": "^3.3.0", "@types/random-words": "^1.1.2", "@types/react-router-dom": "^5.3.3", "@typescript-eslint/eslint-plugin": "^5.43.0", @@ -4217,6 +4219,12 @@ "integrity": "sha512-xZmuPTa3rlZoIbtDUyJKZQimJV3bxCmzMIO2c9Pz9afyDro6kr7R79GwcB6mRhuoPmV2p1Vb66WOJH7F886WKQ==", "dev": true }, + "node_modules/@types/luxon": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.0.tgz", + "integrity": "sha512-uKRI5QORDnrGFYgcdAVnHvEIvEZ8noTpP/Bg+HeUzZghwinDlIS87DEenV5r1YoOF9G4x600YsUXLWZ19rmTmg==", + "dev": true + }, "node_modules/@types/node": { "version": "14.17.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.5.tgz", @@ -12661,6 +12669,14 @@ "node": ">=10" } }, + "node_modules/luxon": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.3.0.tgz", + "integrity": "sha512-An0UCfG/rSiqtAIiBPO0Y9/zAnHUZxAMiCpTd5h2smgsj7GGmcenvrvww2cqNA8/4A5ZrD1gJpHN2mIHZQF+Mg==", + "engines": { + "node": ">=12" + } + }, "node_modules/lz-string": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", @@ -18452,6 +18468,12 @@ "integrity": "sha512-xZmuPTa3rlZoIbtDUyJKZQimJV3bxCmzMIO2c9Pz9afyDro6kr7R79GwcB6mRhuoPmV2p1Vb66WOJH7F886WKQ==", "dev": true }, + "@types/luxon": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.0.tgz", + "integrity": "sha512-uKRI5QORDnrGFYgcdAVnHvEIvEZ8noTpP/Bg+HeUzZghwinDlIS87DEenV5r1YoOF9G4x600YsUXLWZ19rmTmg==", + "dev": true + }, "@types/node": { "version": "14.17.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.5.tgz", @@ -24839,6 +24861,11 @@ "yallist": "^4.0.0" } }, + "luxon": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.3.0.tgz", + "integrity": "sha512-An0UCfG/rSiqtAIiBPO0Y9/zAnHUZxAMiCpTd5h2smgsj7GGmcenvrvww2cqNA8/4A5ZrD1gJpHN2mIHZQF+Mg==" + }, "lz-string": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", diff --git a/package.json b/package.json index 89e75aca..2b4f651d 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@testing-library/react": "^13.4.0", "@types/js-cookie": "^3.0.2", "@types/lodash": "^4.14.175", + "@types/luxon": "^3.3.0", "@types/random-words": "^1.1.2", "@types/react-router-dom": "^5.3.3", "@typescript-eslint/eslint-plugin": "^5.43.0", @@ -76,6 +77,7 @@ "@types/react-dom": "^18.0.5", "js-cookie": "^2.2.1", "lodash": "^4.17.21", + "luxon": "^3.3.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.3.0"