import Helpers from './Helpers' import { DAY_MODE, DAY_TIME_POINT } from './typings' export type calendarWeekType = Date[] export type calendarMonthType = calendarWeekType[] export type calendarYearMonths = Date[] export enum WEEK_START_DAY { SUNDAY = 'sunday', MONDAY = 'monday' } export default class Time { FIRST_DAY_OF_WEEK: WEEK_START_DAY CALENDAR_LOCALE: string ALL_HOURS: DAY_TIME_POINT[] = [ 0, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900, 2000, 2100, 2200, 2300 ] DAY_START: DAY_TIME_POINT DAY_END: DAY_TIME_POINT HOURS_PER_DAY = 24 MS_PER_DAY: number constructor( firstDayOfWeek: WEEK_START_DAY = WEEK_START_DAY.MONDAY, locale: string | null = null, dayBoundaries: { start: DAY_TIME_POINT, end: DAY_TIME_POINT } = { start: 0, end: 2400 } ) { this.FIRST_DAY_OF_WEEK = firstDayOfWeek this.CALENDAR_LOCALE = locale || Helpers.getBrowserNavigatorLocale() this.DAY_START = dayBoundaries.start this.DAY_END = dayBoundaries.end this.HOURS_PER_DAY = (() => { const dayEnd = Time.getHourFromTimePoints(this.DAY_END) const dayStart = Time.getHourFromTimePoints(this.DAY_START) if (dayEnd > dayStart) { return dayEnd - dayStart } return this.HOURS_PER_DAY - dayStart + dayEnd })() this.MS_PER_DAY = 86400000 } get dayMode() { if (this.DAY_START === 0 && this.DAY_END === 2400) { return DAY_MODE.REGULAR } if (this.DAY_START >= this.DAY_END) { return DAY_MODE.FLEXIBLE } return DAY_MODE.SHORTENED } getDatesBetweenTwoDates(start: Date, end: Date): Date[] { const arr = [] const dt = new Date(start) // eslint-disable-next-line no-unmodified-loop-condition while (dt <= end) { arr.push(new Date(dt.getFullYear(), dt.getMonth(), dt.getDate())) dt.setDate(dt.getDate() + 1) } return arr } getCalendarWeekDateObjects(date: Date): calendarWeekType { // If week starts on Sunday, we can get the first date of the week, by simply counting selectedDate.getDate() - selectedDate.getDay() let subtractedDaysToGetFirstDate if (this.FIRST_DAY_OF_WEEK === 'sunday') { subtractedDaysToGetFirstDate = date.getDay() } // However, if week starts on Monday, we need to make sure Mondays are represented as 0, instead of Sundays else { subtractedDaysToGetFirstDate = date.getDay() === 0 ? 6 : date.getDay() - 1 } const dateOfFirstDayOfWeek = date.getDate() - subtractedDaysToGetFirstDate // First date of week is the date of the month - the day of the week const firstDay = new Date( date.getFullYear(), date.getMonth(), dateOfFirstDayOfWeek ) return this.getDatesBetweenTwoDates( firstDay, new Date( firstDay.getFullYear(), firstDay.getMonth(), firstDay.getDate() + 6 ) ) } /** * Returns an array of the weeks that comprise a month * * @param {number} yyyy * @param {number} mm - zero indexed (January === 0) */ getCalendarMonthSplitInWeeks(yyyy: number, mm: number): calendarMonthType { const month: calendarMonthType = [] const selectedDate = new Date(yyyy, mm, 1) // 1. Get the first date of the month, and push the full week of this date into the month list const firstDateOfMonth = new Date( selectedDate.getFullYear(), selectedDate.getMonth(), 1 ) const firstWeekOfMonth = this.getCalendarWeekDateObjects(firstDateOfMonth) month.push(firstWeekOfMonth) // 2. Then enter a while-loop, which pushes weeks onto the month, // until the first Monday is reached, that is not in the specified month let isInMonth = true let mondayOfWeekToPush = firstWeekOfMonth[0] const specifiedMonth = selectedDate.getMonth() while (isInMonth) { const newMonday = new Date( mondayOfWeekToPush.getFullYear(), mondayOfWeekToPush.getMonth(), mondayOfWeekToPush.getDate() + 7 ) if (newMonday.getMonth() === specifiedMonth) { month.push(this.getCalendarWeekDateObjects(newMonday)) mondayOfWeekToPush = newMonday } else { isInMonth = false } } return month } /** * Returns an array with the length of 12 dates, * one date for the first day of each month of the year */ getCalendarYearMonths(year: number): calendarYearMonths { const yearList: calendarYearMonths = [] let month = 0 while (month <= 11) { yearList.push(new Date(year, month, 1)) month++ } return yearList } getHourAndMinutesFromTimePoints(timePoints: number) { const time = timePoints.toString() let hour = '0' let minutes = '0' if (time.length === 4) { hour = time[0] + time[1] minutes = time[2] + time[3] } else if (time.length === 3) { hour = time[0] minutes = time[1] + time[2] } return { hour: +hour, minutes: +minutes, } } /** * Given timePoints (0, 100, 200 etc.), this function returns * a localized string with the respective hour * (in en-US for example: 0 => 12 AM, 1600 => 4 PM ) */ getHourLocaleStringFromHourDigits(timePoints: number) { const { hour, minutes } = this.getHourAndMinutesFromTimePoints(timePoints) const hourLocaleString = new Date( 2100, 0, 1, +hour, +minutes, 0 ).toLocaleTimeString(this.CALENDAR_LOCALE, { hour: '2-digit', }) if (hourLocaleString[0] === '0') { return hourLocaleString.substring(1) } return hourLocaleString } getLocalizedNameOfWeekday( date: Date, weekdayNameLength: 'long' | 'short' = 'short' ): string { return date.toLocaleDateString(this.CALENDAR_LOCALE, { weekday: weekdayNameLength, }) } getLocalizedNameOfMonth( date: Date, monthNameLength: 'long' | 'short' = 'short' ): string { return date.toLocaleDateString(this.CALENDAR_LOCALE, { month: monthNameLength, }) } getLocalizedDateString(date: Date): string { return date.toLocaleDateString(this.CALENDAR_LOCALE) } /** * Takes a date object, and creates a time string from it, in the format of * YYYY-MM-DD hh:mm */ getDateTimeStringFromDate( date: Date, timeIsStartOrEndOfDay?: 'start' | 'end' ): string { const y = date.getFullYear() const m = date.getMonth() + 1 const d = date.getDate() const fullDate = `${y}-${m >= 10 ? m : `0${m}`}-${d >= 10 ? d : `0${d}`}` if (!timeIsStartOrEndOfDay) { const hour = date.getHours() const minutes = date.getMinutes() return `${fullDate} ${hour >= 10 ? hour : `0${hour}`}:${ minutes >= 10 ? minutes : `0${minutes}` }` } const fullTime = timeIsStartOrEndOfDay === 'start' ? '00:00' : '23:59' return `${fullDate} ${fullTime}` } getLocalizedTime(dateTimeString: string) { // Though only displaying time, the exact date is needed, because otherwise time will be displayed // incorrectly on days when daylight saving time changes const { year, month, date, hour, minutes, } = this.getAllVariablesFromDateTimeString(dateTimeString) const d = new Date(year, month, date, hour, minutes) return d.toLocaleTimeString(this.CALENDAR_LOCALE, { hour: 'numeric', minute: 'numeric', }) } getLocalizedHour(date: Date) { return date.toLocaleTimeString(this.CALENDAR_LOCALE, { hour: '2-digit' }) } getLocalizedTimeRange(start: string, end: string) { return `${this.getLocalizedTime(start)} - ${this.getLocalizedTime(end)}` } /** * Returns numeric values for year, month, date, hour and minutes, given a dateTimeString * All variables are Date-Object compatible, meaning "month" is zero-indexed */ getAllVariablesFromDateTimeString(dateTimeString: string) { return { year: +dateTimeString.substring(0, 4), month: +dateTimeString.substring(5, 7) - 1, date: +dateTimeString.substring(8, 10), hour: this.hourFrom(dateTimeString), minutes: this.minutesFrom(dateTimeString), } } dateIsToday(date: Date) { const today = new Date() const yearToday = today.getFullYear() const monthToday = today.getMonth() const dateToday = today.getDate() const dateYear = date.getFullYear() const dateMonth = date.getMonth() const dateDate = date.getDate() return ( yearToday === dateYear && monthToday === dateMonth && dateToday === dateDate ) } dateIsInWeek(dateToCheck: Date, week: Date[]) { const d = new Date(dateToCheck) const date = d.getDate() const month = d.getMonth() const fullYear = d.getFullYear() for (const weekDay of week) { const dateIsSame = date === weekDay.getDate() const monthIsSame = month === weekDay.getMonth() const yearIsSame = fullYear === weekDay.getFullYear() if (dateIsSame && monthIsSame && yearIsSame) { return true } } return false } getDateStringFromDate(date: Date) { const yyyy = date.getFullYear() const mm = (date.getMonth() + 1) const dd = date.getDate() return `${yyyy}-${mm >= 10 ? mm : `0${mm}`}-${dd >= 10 ? dd : `0${dd}`}` } addMinutesToDateTimeString(minutes: number, dateTimeString: string) { const { year: oldYear, month: oldMonth, date: oldDate, hour: oldHour, minutes: oldMinutes } = this.getAllVariablesFromDateTimeString(dateTimeString) const oldDateObject = new Date(oldYear, oldMonth, oldDate, oldHour, oldMinutes) const newDateObject = new Date(oldDateObject.getTime() + (minutes * 60000)) return this.getDateTimeStringFromDate(newDateObject) } addDaysToDateTimeString(days: number, dateTimeString: string) { return this.addMinutesToDateTimeString((days * 1440), dateTimeString) } dateStringsHaveEqualDates(dateTimeString1: string, dateTimeString2: string) { const { year: year1, month: month1, date: date1 } = this.getAllVariablesFromDateTimeString(dateTimeString1) const { year: year2, month: month2, date: date2 } = this.getAllVariablesFromDateTimeString(dateTimeString2) return (year1 === year2) && (month1 === month2) && (date1 === date2) } setDateToEndOfDay(date: Date) { return new Date( date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999 ) } protected turnMinutesIntoPercentageOfHour(minutes: number): string { const oneMinutePercentage = 100 / 60 const minutePoints = oneMinutePercentage * minutes if (minutePoints < 10) { return `0${minutePoints}` } return minutePoints.toString() } /** * Every hour between 'dayStart' and 'dayEnd' is 100, in this function referred to as 100 points * If an event starts 30 minutes after 'dayStart', it should have 50 pointsIntoDay * If a day consists of 4 hours (400 points), we then have to count * (50 / 400) * 100 = 12.5 => event starts after 12.5 percent of the day * * Result is supposed to be a number between 0 and 100, and is used for setting the CSS- top- and height-attributes for events */ getPercentageOfDayFromDateTimeString( dateTimeString: string, dayStart: number, dayEnd: number, ) { const hour = this.hourFrom(dateTimeString) const hourPoints = hour * 100 const minutes = +dateTimeString.substring(14, 16) const minutesPoints = +this.turnMinutesIntoPercentageOfHour(+minutes) if (dayEnd > dayStart) { const pointsInDay = dayEnd - dayStart const eventPoints = hourPoints + minutesPoints const eventPointsIntoDay = eventPoints - dayStart return (eventPointsIntoDay / pointsInDay) * 100 } const hourPointsInOriginalDay = DAY_TIME_POINT.TWELVE_AM - dayStart const pointsInDay = hourPointsInOriginalDay + dayEnd const eventPoints = hourPoints + minutesPoints const pointsIntoDay = eventPoints >= dayStart ? eventPoints - dayStart : hourPointsInOriginalDay + eventPoints return (pointsIntoDay / pointsInDay) * 100 } getTimeFromClick( clickOffsetY: number, weekHeight: number, ): string { if (weekHeight <= 0) { throw new Error('weekHeight cannot be a negative number') } if (clickOffsetY < 0) { throw new Error('clickOffsetY cannot be a negative number') } const dayStartHour = this.DAY_START / 100 const hourHeight = weekHeight / this.HOURS_PER_DAY const minutes = Math.floor((clickOffsetY % hourHeight) / (hourHeight / 60)) if (this.DAY_END <= this.DAY_START) { const dayEndHour = this.DAY_END / 100 const nightHeight = (dayEndHour) * hourHeight const nightOffset = weekHeight - nightHeight if (clickOffsetY > nightOffset) { const hour = Math.floor((clickOffsetY - nightOffset) / hourHeight) return `${this.doubleDigit(hour)}:${this.doubleDigit(minutes)}` } } const hour = Math.floor(clickOffsetY / hourHeight) + dayStartHour return `${this.doubleDigit(hour)}:${this.doubleDigit(minutes)}` } setSegmentOfDateTimeString(dateTimeString: string, segments: { hour: number }) { if (segments.hour < 0 || segments.hour > 23) { throw new Error('Invalid hour') } const newHour = this.doubleDigit(segments.hour) dateTimeString = dateTimeString.replace(/\d{2}:/, `${newHour}:`) return dateTimeString } isTrailingOrLeadingDate(date: Date, month: number) { const d = new Date(date) const dateMonth = d.getMonth() return month !== dateMonth } static getTimePointsFromHour(boundary: number) { if (boundary < 0 || boundary > 24 || boundary % 1 !== 0) { throw new Error('Invalid day boundary') } if (boundary === 0) { return boundary } return boundary * 100 } static getHourFromTimePoints(timePoints: number) { if (timePoints < 0 || timePoints > 2400 || timePoints % 100 !== 0) { throw new Error('Invalid time points') } if (timePoints === 0) { return timePoints } return timePoints / 100 } getTimelineHours(): DAY_TIME_POINT[] { if (this.dayMode !== DAY_MODE.FLEXIBLE) { return this.ALL_HOURS.filter((hour) => { return hour >= this.DAY_START && hour < this.DAY_END }) } return [ ...this.ALL_HOURS.filter(hour => hour >= this.DAY_START), ...this.ALL_HOURS.filter(hour => hour < this.DAY_END) ] } dateStringFrom(dateTimeString: string) { return dateTimeString.substring(0, 10) } timeStringFrom(dateTimeString: string) { return dateTimeString.substring(11, 16) } hourFrom(dateTimeString: string) { return +dateTimeString.substring(11, 13) } minutesFrom(dateTimeString: string) { return +dateTimeString.substring(14, 16) } areDaysConsecutive(dayOne: string, dayTwo: string) { const dayOnePlusOneDay = this.dateStringFrom(this.addDaysToDateTimeString(1, dayOne)) return dayOnePlusOneDay === this.dateStringFrom(dayTwo) } setHourInDateTimeString(dateTimeString: string, hour: number) { const hourString = this.doubleDigit(hour) dateTimeString = dateTimeString.replace(/\d{2}:/, `${hourString}:`) return dateTimeString } setMinutesInDateTimeString(dateTimeString: string, minutes: number) { const minutesString = this.doubleDigit(minutes) dateTimeString = dateTimeString.replace(/:\d{2}/, `:${minutesString}`) return dateTimeString } getDateTimeStringDayBoundariesFrom(dateString: string): { start: string, end: string } { if (this.DAY_END <= this.DAY_START) { const nextDay = this.addDaysToDateTimeString(1, dateString) const endOfDay = this.setHourInDateTimeString(nextDay, this.getHourAndMinutesFromTimePoints(this.DAY_END).hour) const startOfDay = this.setHourInDateTimeString(dateString, this.getHourAndMinutesFromTimePoints(this.DAY_START).hour) return { start: startOfDay, end: endOfDay } } const startOfDay = this.setHourInDateTimeString(dateString, this.getHourAndMinutesFromTimePoints(this.DAY_START).hour) let endOfDay if (this.DAY_END === DAY_TIME_POINT.TWELVE_AM) { endOfDay = this.setHourInDateTimeString(dateString, 23) endOfDay = this.setMinutesInDateTimeString(endOfDay, 59) } else { endOfDay = this.setHourInDateTimeString(dateString, this.getHourAndMinutesFromTimePoints(this.DAY_END).hour) } return { start: startOfDay, end: endOfDay } } doubleDigit(number: number) { if (number < 0 || number > 60) { throw new Error('Invalid number. This is not a valid hour or minute') } return number < 10 ? `0${number}` : String(number) } } export class TimeBuilder { private weekStartsOn: WEEK_START_DAY = WEEK_START_DAY.MONDAY private locale: string | null = null private dayBoundaries: { start: DAY_TIME_POINT, end: DAY_TIME_POINT } = { start: 0, end: 2400 } build() { return new Time(this.weekStartsOn, this.locale, this.dayBoundaries) } withDayBoundaries(dayBoundaries: { start: DAY_TIME_POINT, end: DAY_TIME_POINT }) { this.dayBoundaries = dayBoundaries return this } withLocale(locale: string) { this.locale = locale return this } }