shared/api/models/DateModel.ts
import { DateTime, Duration } from 'luxon'
import { RRule as RRuleType, rrulestr } from 'rrule'
import { formatDateICal } from '../../utils'
const MAX_RECURRENCE_YEARS = 5
export type DateIcon = 'CalendarTodayRecurringIcon' | 'CalendarRecurringIcon' | 'CalendarTodayIcon'
class DateModel {
_startDate: DateTime
_endDate: DateTime
_allDay: boolean
_recurrenceRule: RRuleType | null
_offset: number
_duration: Duration
constructor({
startDate,
endDate,
allDay,
recurrenceRule,
offset,
}: {
startDate: DateTime
endDate: DateTime
allDay: boolean
recurrenceRule: RRuleType | null
offset?: number
}) {
this._recurrenceRule = recurrenceRule
this._offset = offset ?? startDate.offset
this._allDay = allDay
this._duration = endDate.diff(startDate)
this._startDate = startDate
this._endDate = endDate
}
// This should only be called on recurrences as start dates are not updated in the CMS
// E.g. date.recurrences(1)[0]?.startDate
get startDate(): DateTime {
return this._startDate
}
// This should only be called on recurrences as end dates are not updated in the CMS
// E.g. date.recurrences(1)[0]?.endDate
get endDate(): DateTime {
return this._endDate
}
get allDay(): boolean {
return this._allDay
}
get recurrenceRule(): RRuleType | null {
return this._recurrenceRule
}
get offset(): number {
return this._offset
}
get isToday(): boolean {
const now = DateTime.now()
return (
this.startDate.hasSame(now, 'day') ||
this.endDate.hasSame(now, 'day') ||
(this.startDate <= now && this.endDate >= now)
)
}
recurrences(count: number): DateModel[] {
if (!this.recurrenceRule) {
return [this]
}
const now = DateTime.now()
const duration = this._endDate.diff(this._startDate)
const minDate = now.minus(duration).minus({ minutes: now.offset }).toJSDate() // to also include events that are happening right now
const maxDate = now.plus({ years: MAX_RECURRENCE_YEARS }).toJSDate()
// The rrule package considers all times to be in UTC time zones and ignores time zone offsets
// So we manually subtract the offset before getting the recurrences and add it back in after
// If we don't subtract the offset for the recurrences, we get the wrong date if the offset is
// bigger than the distance from midnight (e.g. 1 am with a 2h offset during CET summer time)
// https://github.com/jkbrzt/rrule#important-use-utc-dates
const localRecurrenceRule = this.getRecurrenceRuleInLocalTime(this.recurrenceRule)
return localRecurrenceRule
.between(minDate, maxDate, true, (_, index) => index < count)
.map(offsetDate => {
const actualDate = DateTime.fromJSDate(offsetDate).plus({ minutes: offsetDate.getTimezoneOffset() })
return new DateModel({
allDay: this.allDay,
startDate: actualDate,
endDate: actualDate.plus(duration),
recurrenceRule: this.recurrenceRule,
offset: this.offset,
})
})
}
hasMoreRecurrencesThan(count: number): boolean {
return this.recurrences(count + 1).length === count + 1
}
toFormattedString(locale: string, short = false): string {
const dayFormat = short ? 'D' : 'DDD'
const format = this.allDay ? dayFormat : `${dayFormat} t`
const localizedStartDate = this.startDate.setLocale(locale).toFormat(format)
const localizedEndDate = this.endDate.setLocale(locale)
if (this.startDate.equals(this.endDate)) {
return localizedStartDate
}
if (this.startDate.hasSame(this.endDate, 'day')) {
return this.allDay ? localizedStartDate : `${localizedStartDate} - ${localizedEndDate.toFormat('t')}`
}
return `${localizedStartDate} - ${localizedEndDate.toFormat(format)}`
}
getDateIcon(): { icon: DateIcon; label: string } | null {
const isRecurring = this.hasMoreRecurrencesThan(1)
const isToday = this.isToday
if (isRecurring && isToday) {
return { icon: 'CalendarTodayRecurringIcon', label: 'todayRecurring' }
}
if (isRecurring) {
return { icon: 'CalendarRecurringIcon', label: 'recurring' }
}
if (isToday) {
return { icon: 'CalendarTodayIcon', label: 'today' }
}
return null
}
private getRecurrenceRuleInLocalTime(recurrenceRule: RRuleType): RRuleType {
const startDate = recurrenceRule.options.dtstart
const offsetStartDate = formatDateICal(
DateTime.fromJSDate(startDate).minus({ minutes: startDate.getTimezoneOffset() }).toUTC(),
)
const regexForFindingDate = /\d{8}T\d{6}/
// Don't parse by the recurrenceRule options here, rrule doesn't properly parse the params for every nth day of the month
// https://github.com/jkbrzt/rrule/issues/326
return rrulestr(recurrenceRule.toString().replace(regexForFindingDate, offsetStartDate))
}
isEqual(other: DateModel): boolean {
return this.startDate.equals(other.startDate) && this.endDate.equals(other.endDate) && this.allDay === other.allDay
}
}
export default DateModel