Skip to content

Commit

Permalink
Add calendar to mentor attendance view
Browse files Browse the repository at this point in the history
  • Loading branch information
smartspot2 committed Sep 11, 2024
1 parent 274a306 commit 44ce955
Show file tree
Hide file tree
Showing 11 changed files with 593 additions and 120 deletions.
274 changes: 168 additions & 106 deletions csm_web/frontend/src/components/section/MentorSectionAttendance.tsx

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion csm_web/frontend/src/components/section/Section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export function SectionSidebar({ links }: SectionSidebarProps) {
return (
<nav id="section-detail-sidebar">
{links.map(([label, href]) => (
<NavLink end to={href} key={href}>
<NavLink end to={`${href}`} key={href}>
{label}
</NavLink>
))}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { DateTime, Info } from "luxon";
import React, { useEffect, useState } from "react";

import LeftArrow from "../../../../static/frontend/img/angle-left-solid.svg";
import RightArrow from "../../../../static/frontend/img/angle-right-solid.svg";

import "../../../css/calendar-month.scss";

interface CalendarMonthProps {
// List of dates that have occurrences, in ISO format
occurrenceDates: string[];
// Mapping between occurrence dates and text to display for that occurrence.
// Keys should match items in `occurrenceDates` exactly; some elements can be omitted.
occurrenceTextMap: Map<string, string>;
selectedOccurrence?: string;
// click handler; the date in ISO format is passed in as an argument
onClickDate: (day: string) => void;
}

export const CalendarMonth = ({
occurrenceDates,
occurrenceTextMap,
selectedOccurrence,
onClickDate
}: CalendarMonthProps) => {
/**
* Current ISO month number.
*/
const [curMonth, setCurMonth] = useState<number>(DateTime.now().month);
/**
* Current year.
*/
const [curYear, setCurYear] = useState<number>(DateTime.now().year);

useEffect(() => {
if (selectedOccurrence != null) {
// upon change of the selected occurence, make sure the calendar also matches
const selectedDateTime = DateTime.fromISO(selectedOccurrence);
if (curMonth !== selectedDateTime.month) {
setCurMonth(selectedDateTime.month);
}
if (curYear != selectedDateTime.year) {
setCurYear(selectedDateTime.year);
}
}
}, [selectedOccurrence]);

const modifyMonth = (diff: number) => {
const curDate = DateTime.fromObject({ year: curYear, month: curMonth });
const nextDate = curDate.plus({ months: diff });

setCurMonth(nextDate.month);
setCurYear(nextDate.year);
};

/**
* Navigate to the current month.
*/
const handleToday = () => {
const today = DateTime.now();
setCurMonth(today.month);
setCurYear(today.year);
};

/**
* Compute the weekday index from an ISO weekday number (1-7).
* This accounts for any shifting that we need to perform to display days in the calendar.
*/
const weekdayIndexFromISO = (weekday: number) => {
return weekday % 7;
};

const weekdayISOFromIndex = (idx: number) => {
return ((idx + 6) % 7) + 1;
};

const curMonthFirstDay = DateTime.fromObject({ year: curYear, month: curMonth, day: 1 });
const nextMonthFirstDay = curMonthFirstDay.plus({ months: 1 });

const monthGrid: React.ReactNode[][] = [];
// push empty days until the first day of the month
const firstWeekPadding = [...Array(weekdayIndexFromISO(curMonthFirstDay.weekday))].map((_, idx) => (
<CalendarMonthDay key={-idx} year={-1} month={-1} day={-1} isoDate="" hasOccurrence={false} selected={false} />
));
monthGrid.push(firstWeekPadding);

for (let date = curMonthFirstDay; date < nextMonthFirstDay; date = date.plus({ days: 1 })) {
// get last week in month grid
const curWeek = monthGrid[monthGrid.length - 1];

const curDay = (
<CalendarMonthDay
key={date.day}
year={date.year}
month={date.month}
day={date.day}
isoDate={date.toISODate() ?? ""}
hasOccurrence={occurrenceDates.includes(date.toISODate()!)}
text={occurrenceTextMap.get(date.toISODate())}
selected={date.toISODate() === selectedOccurrence}
onClickDate={onClickDate}
/>
);

if (curWeek.length < 7) {
curWeek.push(curDay);
} else {
monthGrid.push([curDay]);
}
}

return (
<div className="calendar-month-container">
<div className="calendar-month-header">
<div className="calendar-month-header-left">
<span className="calendar-month-title">
{Info.months()[curMonth - 1]} {curYear}
</span>
</div>
<div className="calendar-month-header-right">
<button className="calendar-month-today-btn" onClick={handleToday}>
Today
</button>
<LeftArrow className="icon calendar-month-nav-icon" onClick={() => modifyMonth(-1)} />
<RightArrow className="icon calendar-month-nav-icon" onClick={() => modifyMonth(1)} />
</div>
</div>
<div className="calendar-month-weekday-headers">
{[...Array(7)].map((_, idx) => (
<div key={idx} className="calendar-month-weekday-header">
{Info.weekdays("short")[weekdayISOFromIndex(idx) - 1]}
</div>
))}
</div>
<div className="calendar-month-grid">
{monthGrid.map((monthGridWeek, idx) => (
<div key={idx} className="calendar-month-week">
{monthGridWeek}
</div>
))}
</div>
</div>
);
};

interface CalendarMonthDayProps {
year: number;
month: number;
day: number;
isoDate: string;
// Text to be displayed in the calendar day
text?: string;
hasOccurrence: boolean;
selected: boolean;
onClickDate?: (date: string) => void;
}

/**
* Calendar month day component.
*
* If `day` is -1, displays an empty box.
*/
export const CalendarMonthDay = ({
year,
month,
day,
isoDate,
text,
hasOccurrence,
selected,
onClickDate
}: CalendarMonthDayProps) => {
const today = DateTime.now();
const curDate = DateTime.fromObject({ year, month, day });
const isTransparent = day === -1;
const classes = ["calendar-month-day"];
if (isTransparent) {
// transparent higher priority than disabled
classes.push("transparent");
} else if (selected) {
classes.push("selected");
} else if (hasOccurrence) {
classes.push("with-occurrence");
}

if (year === today.year && month === today.month && day == today.day) {
classes.push("today");
} else if (curDate < today) {
classes.push("past");
}

const handleClick = () => {
if (onClickDate != null && !selected && hasOccurrence) {
onClickDate(isoDate);
}
};

return (
<div className={classes.join(" ")} onClick={handleClick}>
{isTransparent ? (
<span></span>
) : (
<>
<span className="calendar-month-day-number">{day}</span>
<span className="calendar-month-day-text">{text}</span>
</>
)}
</div>
);
};
6 changes: 6 additions & 0 deletions csm_web/frontend/src/css/base/_variables.scss
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ $calendar-hover-event: #d2ffd2;
$calendar-hover-shadow: #ccc;
$calendar-select-event: #90ee90;

/// month calendar colors
$calendar-day-occurrence: #d2ffd2;
$calendar-day-occurrence-hover: #bce6bc;
$calendar-day-selected: #9bdaff;
$calendar-day-today: #444;

/// Fonts
$default-font: "Montserrat", sans-serif;

Expand Down
135 changes: 135 additions & 0 deletions csm_web/frontend/src/css/calendar-month.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
@use "base/variables" as *;

.calendar-month-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 5px 0;
}

.calendar-month-header-left {
display: flex;
}

.calendar-month-header-right {
display: flex;
gap: 4px;
align-items: center;
}

.calendar-month-title {
font-size: 1.5rem;
font-weight: bold;
text-align: center;
}

.calendar-month-nav-icon {
width: 0.9rem;
height: fit-content;
margin: 0 8px;

color: #333;

cursor: pointer;
user-select: none;
}

.calendar-month-nav-icon:hover {
color: #888;
}

.calendar-month-today-btn {
width: fit-content;
padding: 4px 8px;
margin: 8px 16px 8px 0;

font-weight: bold;
color: #333;
cursor: pointer;
user-select: none;
background-color: transparent;

border: 2px solid #555;
border-radius: 8px;
}

.calendar-month-today-btn:hover {
color: #888;
border-color: #aaa;
}

.calendar-month-weekday-headers {
display: grid;
grid-template-columns: repeat(7, 5.5em);
gap: 2px;

padding: 5px 0;

text-align: center;
user-select: none;
background-color: $calendar-header-bg;
}

.calendar-month-grid {
display: grid;
grid-template-columns: repeat(7, 5.5em);
gap: 2px;

background-color: $calendar-border;
border: 2px solid $calendar-border;
}

.calendar-month-week {
display: contents;
}

.calendar-month-day {
position: relative;
box-sizing: border-box;
height: 4em;

user-select: none;

border: 2px solid transparent;
}

.calendar-month-day:not(.transparent) {
background-color: $calendar-day-bg;
}

.calendar-month-day.with-occurrence {
background-color: $calendar-day-occurrence;
}

.calendar-month-day.with-occurrence:hover {
cursor: pointer;
background-color: $calendar-day-occurrence-hover;
}

.calendar-month-day.selected {
background-color: $calendar-day-selected;
}

.calendar-month-day.today {
border-color: $calendar-day-today;
}

.calendar-month-day.past {
filter: brightness(0.95);
}

.calendar-month-day-number {
position: absolute;
top: 3px;
left: 3px;
}

.calendar-month-day-text {
position: absolute;
right: 3px;
bottom: 3px;

font-style: italic;
color: #555;
}
Loading

0 comments on commit 44ce955

Please sign in to comment.