Skip to content

Commit 025eff6

Browse files
authored
Add calendar to mentor attendance view (#475)
* Add calendar to mentor attendance view * Fix timezone bug when comparing dates in the mentor attendance calendar * Default to most recent past occurrence instead of most recent future occurrence * Fix initial attendance bug, add icon for unmarked attendances * Update cypress tests for changes in initial date selection, update student wotd date selection
1 parent 34d940c commit 025eff6

16 files changed

+788
-165
lines changed

csm_web/frontend/src/components/section/MentorSectionAttendance.tsx

+230-107
Large diffs are not rendered by default.

csm_web/frontend/src/components/section/Section.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export function SectionSidebar({ links }: SectionSidebarProps) {
6464
return (
6565
<nav id="section-detail-sidebar">
6666
{links.map(([label, href]) => (
67-
<NavLink end to={href} key={href}>
67+
<NavLink end to={`${href}`} key={href}>
6868
{label}
6969
</NavLink>
7070
))}

csm_web/frontend/src/components/section/StudentSection.tsx

+32-8
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
useStudentAttendances,
99
useStudentSubmitWordOfTheDayMutation
1010
} from "../../utils/queries/sections";
11-
import { Mentor, Override, Role, Spacetime } from "../../utils/types";
11+
import { AttendancePresence, Mentor, Override, Role, Spacetime } from "../../utils/types";
1212
import LoadingSpinner from "../LoadingSpinner";
1313
import Modal from "../Modal";
1414
import { ATTENDANCE_LABELS, InfoCard, SectionDetail, SectionSpacetime } from "./Section";
@@ -184,12 +184,32 @@ function StudentSectionAttendance({ associatedProfileId, id }: StudentSectionAtt
184184

185185
useEffect(() => {
186186
if (attendancesLoaded) {
187-
const firstAttendance = [...attendances]
188-
// only allow choosing from dates with blank attendances
189-
.filter(attendance => attendance.presence === "")
190-
// sort and get the first attendance
191-
.sort((a, b) => dateSortISO(a.date, b.date))[0];
192-
setSelectedAttendanceId(firstAttendance?.id ?? -1);
187+
const now = DateTime.now().setZone(DEFAULT_TIMEZONE);
188+
const sortedAttendances = attendances
189+
// only consider dates that have no attendance yet
190+
.filter(attendance => attendance.presence === AttendancePresence.EMPTY)
191+
.sort((a, b) => dateSortISO(a.date, b.date));
192+
193+
const pastAttendances = sortedAttendances.filter(
194+
attendance => DateTime.fromISO(attendance.date, { zone: DEFAULT_TIMEZONE }) <= now.endOf("day")
195+
);
196+
const futureAttendances = sortedAttendances.filter(
197+
attendance => DateTime.fromISO(attendance.date, { zone: DEFAULT_TIMEZONE }) > now.endOf("day")
198+
);
199+
200+
const mostRecentPastAttendanceId = pastAttendances[0]?.id ?? null;
201+
const mostRecentFutureAttendanceId = futureAttendances[futureAttendances.length - 1]?.id ?? null;
202+
203+
if (mostRecentPastAttendanceId !== null) {
204+
// reassign to the most recent attendance in the past
205+
setSelectedAttendanceId(mostRecentPastAttendanceId);
206+
} else if (mostRecentFutureAttendanceId !== null) {
207+
// if no empty attendances in the past, reassign to the most recent attendance in the future
208+
setSelectedAttendanceId(mostRecentFutureAttendanceId);
209+
} else {
210+
// worst-case, we assigng to -1 to indicate that there is no possible attendance left to choose from
211+
setSelectedAttendanceId(-1);
212+
}
193213
}
194214
}, [attendancesLoaded]);
195215

@@ -258,7 +278,7 @@ function StudentSectionAttendance({ associatedProfileId, id }: StudentSectionAtt
258278
<div className="word-of-the-day-action-container">
259279
<div className="word-of-the-day-input-container">
260280
<select
261-
value={selectedAttendanceId}
281+
value={selectedAttendanceId === -1 ? undefined : selectedAttendanceId.toString()}
262282
className="form-select"
263283
name="word-of-the-day-date"
264284
onChange={handleSelectedAttendanceIdChange}
@@ -274,6 +294,10 @@ function StudentSectionAttendance({ associatedProfileId, id }: StudentSectionAtt
274294
{formatDateLocaleShort(occurrence.date)}
275295
</option>
276296
))}
297+
{selectedAttendanceId === -1 && (
298+
// if no current attendance, add an empty option to make this visible in the UI
299+
<option value="" disabled></option>
300+
)}
277301
</select>
278302
<input
279303
className="form-input"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import { DateTime, Info } from "luxon";
2+
import React, { useEffect, useState } from "react";
3+
4+
import { DEFAULT_TIMEZONE } from "../../../utils/datetime";
5+
6+
import LeftArrow from "../../../../static/frontend/img/angle-left-solid.svg";
7+
import RightArrow from "../../../../static/frontend/img/angle-right-solid.svg";
8+
9+
import "../../../css/calendar-month.scss";
10+
11+
interface CalendarMonthProps {
12+
// List of dates that have occurrences, in ISO format
13+
occurrenceDates: string[];
14+
// Mapping between occurrence dates and text to display for that occurrence.
15+
// Keys should match items in `occurrenceDates` exactly; some elements can be omitted.
16+
occurrenceTextMap: Map<string, string>;
17+
// Mapping between occurrence dates and a react component (usually an icon) to display
18+
// in the top-right corner of the container for that occurrence.
19+
// Keys should match items in `occurrenceDates` exactly; some elements can be omitted.
20+
occurrenceIconMap: Map<string, React.ReactNode>;
21+
// currently selected occurrence in the calendar
22+
selectedOccurrence?: string;
23+
// click handler; the date in ISO format is passed in as an argument
24+
onClickDate: (day: string) => void;
25+
}
26+
27+
export const CalendarMonth = ({
28+
occurrenceDates,
29+
occurrenceTextMap,
30+
occurrenceIconMap,
31+
selectedOccurrence,
32+
onClickDate
33+
}: CalendarMonthProps) => {
34+
/**
35+
* Current ISO month number.
36+
*/
37+
const [curMonth, setCurMonth] = useState<number>(DateTime.now().month);
38+
/**
39+
* Current year.
40+
*/
41+
const [curYear, setCurYear] = useState<number>(DateTime.now().year);
42+
43+
useEffect(() => {
44+
if (selectedOccurrence != null) {
45+
// upon change of the selected occurence, make sure the calendar also matches
46+
const selectedDateTime = DateTime.fromISO(selectedOccurrence, { zone: DEFAULT_TIMEZONE });
47+
if (curMonth !== selectedDateTime.month) {
48+
setCurMonth(selectedDateTime.month);
49+
}
50+
if (curYear != selectedDateTime.year) {
51+
setCurYear(selectedDateTime.year);
52+
}
53+
}
54+
}, [selectedOccurrence]);
55+
56+
const modifyMonth = (diff: number) => {
57+
const curDate = DateTime.fromObject({ year: curYear, month: curMonth });
58+
const nextDate = curDate.plus({ months: diff });
59+
60+
setCurMonth(nextDate.month);
61+
setCurYear(nextDate.year);
62+
};
63+
64+
/**
65+
* Navigate to the current month.
66+
*/
67+
const handleToday = () => {
68+
const today = DateTime.now();
69+
setCurMonth(today.month);
70+
setCurYear(today.year);
71+
};
72+
73+
/**
74+
* Compute the weekday index from an ISO weekday number (1-7).
75+
* This accounts for any shifting that we need to perform to display days in the calendar.
76+
*/
77+
const weekdayIndexFromISO = (weekday: number) => {
78+
return weekday % 7;
79+
};
80+
81+
const weekdayISOFromIndex = (idx: number) => {
82+
return ((idx + 6) % 7) + 1;
83+
};
84+
85+
const curMonthFirstDay = DateTime.fromObject({ year: curYear, month: curMonth, day: 1 });
86+
const nextMonthFirstDay = curMonthFirstDay.plus({ months: 1 });
87+
88+
const monthGrid: React.ReactNode[][] = [];
89+
// push empty days until the first day of the month
90+
const firstWeekPadding = [...Array(weekdayIndexFromISO(curMonthFirstDay.weekday))].map((_, idx) => (
91+
<CalendarMonthDay key={-idx} year={-1} month={-1} day={-1} isoDate="" hasOccurrence={false} selected={false} />
92+
));
93+
monthGrid.push(firstWeekPadding);
94+
95+
for (let date = curMonthFirstDay; date < nextMonthFirstDay; date = date.plus({ days: 1 })) {
96+
// get last week in month grid
97+
const curWeek = monthGrid[monthGrid.length - 1];
98+
99+
const curDay = (
100+
<CalendarMonthDay
101+
key={date.day}
102+
year={date.year}
103+
month={date.month}
104+
day={date.day}
105+
isoDate={date.toISODate() ?? ""}
106+
hasOccurrence={occurrenceDates.includes(date.toISODate()!)}
107+
text={occurrenceTextMap.get(date.toISODate()!)}
108+
icon={occurrenceIconMap.get(date.toISODate()!)}
109+
selected={date.toISODate() === selectedOccurrence}
110+
onClickDate={onClickDate}
111+
/>
112+
);
113+
114+
if (curWeek.length < 7) {
115+
curWeek.push(curDay);
116+
} else {
117+
monthGrid.push([curDay]);
118+
}
119+
}
120+
121+
return (
122+
<div className="calendar-month-container">
123+
<div className="calendar-month-header">
124+
<div className="calendar-month-header-left">
125+
<span className="calendar-month-title">
126+
{Info.months()[curMonth - 1]} {curYear}
127+
</span>
128+
</div>
129+
<div className="calendar-month-header-right">
130+
<button className="calendar-month-today-btn" onClick={handleToday}>
131+
Today
132+
</button>
133+
<LeftArrow className="icon calendar-month-nav-icon" onClick={() => modifyMonth(-1)} />
134+
<RightArrow className="icon calendar-month-nav-icon" onClick={() => modifyMonth(1)} />
135+
</div>
136+
</div>
137+
<div className="calendar-month-weekday-headers">
138+
{[...Array(7)].map((_, idx) => (
139+
<div key={idx} className="calendar-month-weekday-header">
140+
{Info.weekdays("short")[weekdayISOFromIndex(idx) - 1]}
141+
</div>
142+
))}
143+
</div>
144+
<div className="calendar-month-grid">
145+
{monthGrid.map((monthGridWeek, idx) => (
146+
<div key={idx} className="calendar-month-week">
147+
{monthGridWeek}
148+
</div>
149+
))}
150+
</div>
151+
</div>
152+
);
153+
};
154+
155+
interface CalendarMonthDayProps {
156+
year: number;
157+
month: number;
158+
day: number;
159+
isoDate: string;
160+
// Text to be displayed in the calendar day
161+
text?: string;
162+
// Icon to be displayed in the calendar day
163+
icon?: React.ReactNode;
164+
hasOccurrence: boolean;
165+
selected: boolean;
166+
onClickDate?: (date: string) => void;
167+
}
168+
169+
/**
170+
* Calendar month day component.
171+
*
172+
* If `day` is -1, displays an empty box.
173+
*/
174+
export const CalendarMonthDay = ({
175+
year,
176+
month,
177+
day,
178+
isoDate,
179+
text,
180+
icon,
181+
hasOccurrence,
182+
selected,
183+
onClickDate
184+
}: CalendarMonthDayProps) => {
185+
const today = DateTime.now();
186+
const curDate = DateTime.fromObject({ year, month, day });
187+
const isTransparent = day === -1;
188+
const classes = ["calendar-month-day"];
189+
if (isTransparent) {
190+
// transparent higher priority than disabled
191+
classes.push("transparent");
192+
} else if (selected) {
193+
classes.push("selected");
194+
} else if (hasOccurrence) {
195+
classes.push("with-occurrence");
196+
}
197+
198+
if (year === today.year && month === today.month && day == today.day) {
199+
classes.push("today");
200+
} else if (curDate < today) {
201+
classes.push("past");
202+
}
203+
204+
const handleClick = () => {
205+
if (onClickDate != null && !selected && hasOccurrence) {
206+
onClickDate(isoDate);
207+
}
208+
};
209+
210+
return (
211+
<div className={classes.join(" ")} onClick={handleClick}>
212+
{isTransparent ? (
213+
<span></span>
214+
) : (
215+
<>
216+
<span className="calendar-month-day-number">{day}</span>
217+
<span className="calendar-month-day-icon">{icon}</span>
218+
<span className="calendar-month-day-text">{text}</span>
219+
</>
220+
)}
221+
</div>
222+
);
223+
};

csm_web/frontend/src/css/base/_variables.scss

+6
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ $calendar-hover-event: #d2ffd2;
4343
$calendar-hover-shadow: #ccc;
4444
$calendar-select-event: #90ee90;
4545

46+
/// month calendar colors
47+
$calendar-day-occurrence: #d2ffd2;
48+
$calendar-day-occurrence-hover: #bce6bc;
49+
$calendar-day-selected: #9bdaff;
50+
$calendar-day-today: #444;
51+
4652
/// Fonts
4753
$default-font: "Montserrat", sans-serif;
4854

0 commit comments

Comments
 (0)