|
| 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 | +}; |
0 commit comments