Skip to content

Instantly share code, notes, and snippets.

@gastonmorixe
Last active March 5, 2021 13:23
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save gastonmorixe/ea615171ce2dc294ea8228046e62827f to your computer and use it in GitHub Desktop.
Save gastonmorixe/ea615171ce2dc294ea8228046e62827f to your computer and use it in GitHub Desktop.
Calendar with Timezones Support
import * as React from "react"
import styled from "styled-components"
import { Observer } from "mobx-react"
// import { createViewModel, ViewModel } from "mobx-utils"
import { Responsive } from "~/components/Responsive"
import { Moment } from "~/utils/moment"
import { Header } from "./Header"
import { MonthView } from "./MonthView"
import { WeekView } from "./WeekView"
import { Store } from "../Store"
import {
storeContext,
ICalendarEvent,
TOnMonthEventClickHandler,
TOnItemDayViewClickHandler,
ISelectOption,
ICalendarProps,
} from "../constants"
import { generateMonthDays } from "./MonthView/utils"
const ViewContent = React.memo(
(props: {
HeaderRightWrapperComponent?: React.SFC,
HeaderViewWrapperComponent?: React.SFC,
ExtraRightHeaderComponent?: React.SFC,
disableViewTypeControls?: boolean,
timezones?: ISelectOption[],
isLoading?: boolean,
isViewMonth?: boolean,
isViewDay?: boolean,
selectedDate: Moment,
onMonthEventClickHandler: TOnMonthEventClickHandler,
onItemDayViewClickHandler: TOnItemDayViewClickHandler,
saveCalendarRemotelyWithDataHandler: () => any,
minEventHeight?: number | string,
}) => {
const {
HeaderRightWrapperComponent,
HeaderViewWrapperComponent,
ExtraRightHeaderComponent,
disableViewTypeControls,
timezones,
isLoading,
isViewMonth,
isViewDay,
selectedDate,
onMonthEventClickHandler,
saveCalendarRemotelyWithDataHandler,
minEventHeight,
onItemDayViewClickHandler,
} = props
console.log("[Calendar] [Views] [ViewContent]", { timezones, props })
return (
<ViewContentWrapper data-test-component="calendar" {...{ isLoading }}>
<Header
{...{
disableViewTypeControls,
saveCalendarRemotelyWithDataHandler,
timezones,
isViewDay,
ExtraRightHeaderComponent,
HeaderRightWrapperComponent,
HeaderViewWrapperComponent,
}}
/>
{isViewMonth && (
<MonthView
{...{
selectedDate,
onMonthEventClickHandler,
onItemDayViewClickHandler,
}}
/>
)}
{!isViewMonth && <WeekView {...{ minEventHeight, selectedDate }} />}
</ViewContentWrapper>
)
}
)
const ViewController = React.memo((props: ICalendarProps) => {
const {
disableViewTypeControls,
defaultViewType,
ExtraRightHeaderComponent,
HeaderRightWrapperComponent,
HeaderViewWrapperComponent,
LoadingComponent,
onMonthEventClickHandler: _onMonthEventClickHandler,
onItemDayViewClickHandler,
isLoading,
minEventHeight,
saveCalendarRemotelyHandler,
forwardRef,
defaultStartTimes,
defaultEventDurationInMinutes,
timezoneName,
timezones,
onDatesCallback,
} = props
const [STATE, setState] = React.useState(null)
React.useEffect(() => {
const STATE = new Store(
defaultStartTimes,
defaultEventDurationInMinutes,
timezoneName,
defaultViewType
)
forwardRef.current = { STATE }
console.log("[Calendar] [ViewPure] [useEffect] #init", {
STATE,
defaultStartTimes,
timezoneName,
defaultViewType,
})
setState(STATE)
if (onDatesCallback) {
onDatesCallback(generateMonthDays(STATE.today)[0])
}
return () => {
console.log("[Calendar] [ViewPure] [useEffect] init effect X DONE")
}
}, [])
React.useEffect(() => {
// Timezone
if (STATE?.setTimezoneName) {
STATE.setTimezoneName(timezoneName)
}
return () => {
console.log(
"[Calendar] [ViewPure] [useEffect] init effect set timezones DONE"
)
}
}, [timezoneName])
const onMonthEventClickHandler = React.useCallback(
({
event,
day,
events,
}: {
event: ICalendarEvent,
events?: ICalendarEvent[],
day: Moment,
}) => {
if (_onMonthEventClickHandler) {
return (ev: React.MouseEvent) => {
console.log("[Calendar] [View]", {
props,
onMonthEventClickHandler,
})
_onMonthEventClickHandler({
events,
timezoneName: STATE.timezoneName.get(),
selectedEvent: event,
day,
})
}
}
},
[_onMonthEventClickHandler, STATE]
)
const saveCalendarRemotelyWithDataHandler = React.useCallback(() => {
const startTimes = STATE.startTimes
saveCalendarRemotelyHandler(startTimes)
}, [STATE])
console.log("[Calendar] [ViewPure] render", { STATE, props })
if (!STATE || !timezones) {
// TODO Maybe prop fallback to show Loader
return LoadingComponent ? <LoadingComponent /> : null
}
return (
<>
{typeof defaultViewType === "undefined" && (
<Responsive>
{({ isMobile }) => {
if (isMobile && STATE.isViewWeek) {
STATE.changeToDayView()
}
return null
}}
</Responsive>
)}
<Observer>
{() => {
const { isViewMonth, isViewDay, selectedDate } = STATE
console.log("[Calendar] [Views] [ViewPure] [Observer] jrk", {
selectedDate,
})
return (
<storeContext.Provider value={STATE}>
<ViewContent
{...{
disableViewTypeControls,
HeaderRightWrapperComponent,
HeaderViewWrapperComponent,
ExtraRightHeaderComponent,
timezones,
isLoading,
isViewMonth,
isViewDay,
selectedDate,
onMonthEventClickHandler,
onItemDayViewClickHandler,
saveCalendarRemotelyWithDataHandler,
minEventHeight,
}}
/>
</storeContext.Provider>
)
}}
</Observer>
</>
)
})
export default React.forwardRef((props, forwardRef) => {
return <ViewController {...props} {...{ forwardRef }} />
})
const ViewContentWrapper = styled.div`
display: flex;
flex-direction: column;
padding: 1.5rem;
background: white;
transition: opacity 0.2s ease-in-out;
opacity: ${(p) => (p.isLoading ? 0.5 : undefined)};
pointer-events: ${(p) => (p.isLoading ? "none" : undefined)};
& * {
pointer-events: ${(p) => (p.isLoading ? "none" : undefined)};
}
& > * + * {
margin-top: 1.5rem;
}
overflow: hidden;
flex: 1;
${(p) => p.theme.mq.mobile} {
overflow: initial;
flex: initial;
flex-grow: 1;
flex-shrink: 0;
padding-bottom: 7rem; /* HubSpot chat bubble overlap fix */
}
${(p) => p.theme.mq.smallHeight} {
overflow: initial;
flex: initial;
flex-grow: 1;
flex-shrink: 0;
}
`
import {
observable,
computed,
action,
$mobx,
observe,
IObservableValue,
// IObservable,
IObservableArray,
} from "mobx"
import { computedFn } from "mobx-utils"
import moment, { Moment } from "~/utils/moment"
import {
ViewModes,
EventType,
IWeekEvents,
ICalendarEvent,
ICalendarStartTime,
} from "./constants"
import { generateEventFromStartTime, generateIntervalTree } from "./utils"
const TODAY_REFRESH_INTERVAL_MINUTES = 1
interface ViewModel {
isDirty: boolean
resetDirty: () => any
}
interface IEventTreeQueryResponse {
high: number
low: number
value: ICalendarEvent
}
interface IStore extends ViewModel {
onStartTimesChangeListenerHandler: (ev: any) => void
onTimeZoneNameListenerHandler: (ev: any) => void
todayRefreshInterval: number
eventsTree: any
hoveringEvent: ICalendarEvent | undefined
selectedDate: Moment
view: ViewModes
timezoneName: IObservableValue<string>
startTimes: IObservableArray<ICalendarStartTime>
hoverStartTimeRaw: Moment | undefined
today: Moment
eventDurationInMinutes: number
isSelectedDateSetByJump: boolean
}
declare global {
interface Window {
__calendar__today__: Moment
}
}
export class Store implements IStore {
@observable isDirty = false
@action.bound
resetDirty() {
this.isDirty = false
}
momentWithTZ = () => {
return moment.tz(this.timezoneName.get())
}
onStartTimesChangeListenerHandler: (ev: any) => void
onTimeZoneNameListenerHandler: (ev: any) => void
todayRefreshInterval: number
// Red-Black augmented interval tree (aka RB Tree)
// https://github.com/pineapplemachine/interval-tree-type-js
@observable eventsTree
// When the mouse is on top of an event
@observable hoveringEvent
@observable selectedDate
@observable view = ViewModes.Week // MONTH must be default!
@observable timezoneName = observable.box(moment.tz.guess())
@observable startTimes = observable.array<ICalendarStartTime>([]) // moment()
@observable hoverStartTimeRaw
// moment.tz(startTime.startsAt, this.timezoneName.get())
@observable today: Moment
@observable eventDurationInMinutes = 30
@observable isSelectedDateSetByJump = false
constructor(
startTimes?: ICalendarStartTime[],
eventDurationInMinutes?: number,
timezoneName?: string,
defaultViewType?: ViewModes
) {
if (window.__calendar__today__) {
this.today = window.__calendar__today__ // for testing
}
if (timezoneName) {
this.timezoneName.set(timezoneName)
}
if (typeof defaultViewType !== "undefined") {
this.view = defaultViewType
}
this.onTodayRefreshIntervalHandler()
this.todayRefreshInterval = setInterval(
this.onTodayRefreshIntervalHandler,
1000 * 60 * TODAY_REFRESH_INTERVAL_MINUTES
)
this.selectedDate = this.momentWithTZ() // TODO From PROP!!! (might come from API user's settings)
this.eventsTree = generateIntervalTree()
this.weekDaysWithEvents = computedFn(this.weekDaysWithEvents)
this.weekDaysWithHoverEvent = computedFn(this.weekDaysWithHoverEvent)
this.eventsQueryForDays = computedFn(this.eventsQueryForDays)
if (eventDurationInMinutes) {
this.eventDurationInMinutes = eventDurationInMinutes
}
if (startTimes && startTimes.length > 0) {
// this.startTimes.splice(0, this.startTimes.length) // This might be useless since at this time array always empty?
// const startTimesMoment: Moment[] | undefined = defaultStartTimes
// ? defaultStartTimes.map(t => moment(t))
// : undefined
this.startTimes.push(
...startTimes.map((startTime) => {
return {
...startTime,
startsAtMoment: moment.tz(
startTime.startsAt,
this.timezoneName.get()
),
}
})
)
}
this.onStartTimesChangeListenerHandler = observe(
this.startTimes,
this.onStartTimesChangeListener,
true
)
this.onTimeZoneNameListenerHandler = observe(
this.timezoneName,
this.onTimeZoneNameListener,
true
)
}
@action.bound
copyForWeeks(weekCount: number) {
const currentWeekStart = this.selectedDate.clone().startOf("week")
const currentWeekEnd = this.selectedDate.clone().endOf("week")
const newStartTimes: ICalendarStartTime[] = []
const eventsTree = this.eventsTree
for (let event1 of eventsTree.queryInterval(
0,
currentWeekStart.clone().subtract(1, "second")
) as IEventTreeQueryResponse[]) {
newStartTimes.push(event1.value.startTime)
}
const currentWeekStartTimes: ICalendarStartTime[] = []
for (let event2 of eventsTree.queryInterval(
currentWeekStart,
currentWeekEnd
) as IEventTreeQueryResponse[]) {
currentWeekStartTimes.push(event2.value.startTime)
}
// Clone this week starTimes for weekCount weeks
for (let i = 0; i < weekCount; i++) {
newStartTimes.push(
...currentWeekStartTimes.map((startTime) => ({
...startTime,
...(i > 0 && { free: undefined }),
startsAtMoment: startTime.startsAtMoment.clone().add(i, "week"),
}))
)
}
console.log("[Calendar] [Store] #weekCount", { newStartTimes })
// Empty start times
this.startTimes.splice(0, this.startTimes.length)
// Re-fill start times
this.startTimes.push(...newStartTimes)
}
// TODO FIX: Should not clear booked events
// @action.bound
// clearAllFutureStartTimes() {
// // TODO Prevent to remove scheduled events
// const newStartTimes = []
// // Mantain
// const today = this.today
// const eventsTree = this.eventsTree
// for (let event of eventsTree.queryInterval(
// 0,
// today
// .clone()
// .subtract(1, "day")
// .endOf("day")
// )) {
// newStartTimes.push(event.value.range.start)
// }
// console.log("[Calendar] [Store] #clearAllFutureStartTimes", {
// newStartTimes,
// store: this
// })
// // Empty start times
// this.startTimes.splice(0, this.startTimes.length)
// // Re-fill start times
// this.startTimes.push(...newStartTimes)
// }
@action.bound
clearAllStartTimes() {
// TODO Prevent to remove scheduled events
const newStartTimes = [
...this.startTimes.filter((startTime) => startTime.free === false),
]
console.log("[Calendar] [Store] #clearAllStartTimes", {
newStartTimes,
store: this,
})
// Empty start times
this.startTimes.splice(0, this.startTimes.length)
// Re-fill start times
this.startTimes.push(...newStartTimes)
}
@action.bound
onTodayRefreshIntervalHandler() {
const newToday = this.momentWithTZ()
if (!this.today || newToday.dayOfYear() !== this.today.dayOfYear()) {
console.log(
"[Calendar] [Store] #onTodayRefreshIntervalHandler updating today",
{
today: this.today,
newToday,
}
)
this.today = newToday
} else {
console.log(
"[Calendar] [Store] #onTodayRefreshIntervalHandler same day",
{
today: this.today,
newToday,
}
)
}
}
@computed
get startTimesAtAndAfterToday() {
// NOTE: If this is too slow, we can use graph.
// TODO Measure for large startTimes arrays.
const todayStartOfDay = this.momentWithTZ().startOf("day")
const result = this.startTimes.filter((startTime) => {
return startTime.startsAtMoment.isSameOrAfter(todayStartOfDay)
})
console.log("[Calendar] [STORE] get #startTimesAtAndAfterToday", {
result,
todayStartOfDay,
})
return result
}
@action.bound
onTimeZoneNameListener(change) {
// To Skip first ?
const timezoneName = this.timezoneName.get()
if (timezoneName !== change.newValue) {
throw new Error("Bug. Timezones name sould be equeal.")
}
console.log("[Calendar] [Store] #onTimeZoneNameListener", {
change,
timezoneNameStore: this.timezoneName,
store: this,
})
if (change.oldValue !== undefined) {
// TODO maybe check type of change and this.timezoneName is defined?
this.today = moment.tz(timezoneName)
const oldSelectedDate = this.selectedDate // no need to clone if we don not mutate it later
let newSelectedDate = this.selectedDate.clone().tz(timezoneName)
if (oldSelectedDate.dayOfYear() !== newSelectedDate.dayOfYear()) {
newSelectedDate.add(1, "day")
}
// if (oldSelectedDate.dayOfYear() !== newSelectedDate.dayOfYear()) {
// newSelectedDate.subtract(2, "day")
// }
this.isSelectedDateSetByJump = false
this.selectedDate = newSelectedDate
// Temp save/clone sartTimes
const originalStartTimes: ICalendarStartTime[] = [...this.startTimes]
// Clear startTimes (this will trigger observable and sync intervals' tree)
this.startTimes.splice(0, this.startTimes.length)
// Remap all startTimes with new timezone
this.startTimes.push(
...originalStartTimes.map((startTime) => ({
...startTime,
startsAtMoment: startTime.startsAtMoment.clone().tz(timezoneName),
}))
)
}
}
onStartTimesChangeListener = (_change) => {
const change: {
added: ICalendarStartTime[]
type: string
removed: ICalendarStartTime[]
} = _change
console.log("[Calendar] [Store] #onStartTimesChangeListener", { change })
if (change.type === "splice") {
if (change.added.length > 0) {
this.isDirty = true
for (let startTime of change.added) {
const event = generateEventFromStartTime(
EventType.SLOT,
this.eventDurationInMinutes,
startTime
)
this.eventsTree.insert(event.range.start, event.range.end, event)
console.log("[Calendar] [Store] #onStartTimesChangeListener [add]", {
eventsTree: this.eventsTree,
event,
})
}
this.updateEventsTree()
}
if (change.removed.length > 0) {
this.isDirty = true
for (let startTime of change.removed) {
const event = generateEventFromStartTime(
EventType.SLOT,
this.eventDurationInMinutes,
startTime
)
const removedEvent = this.eventsTree.remove(
event.range.start,
event.range.end
)
console.log(
"[Calendar] [Store] #onStartTimesChangeListener [remove]",
{ eventsTree: this.eventsTree, event, removedEvent, startTime }
)
}
this.updateEventsTree()
}
}
}
updateEventsTree = () => {
// Force eventsTree observable to update.
const eventsTreeAtom = this[$mobx].values.get("eventsTree")
console.log("[Calendar] [Store] #updateEventsTree", { eventsTreeAtom })
eventsTreeAtom.reportChanged()
}
@action.bound
setTimezoneName(timezoneName: string) {
console.log("[Calendar] [Store] #setTimezoneName", {
timezoneName,
timezoneNameStore: this.timezoneName,
store: this,
})
if (timezoneName !== this.timezoneName.get()) {
this.timezoneName.set(timezoneName)
}
}
@action.bound
setHoveringEvent(event: ICalendarEvent) {
this.hoveringEvent = event
}
@action.bound
clearHoveringEvent(event: ICalendarEvent) {
if (this.hoveringEvent && this.hoveringEvent.uid === event.uid) {
this.hoveringEvent = undefined
}
}
@computed
get canGoPrev() {
if (this.isViewMonth) {
return this.selectedDate
.clone()
.subtract(1, "month")
.isSameOrAfter(this.today, "month")
}
if (this.isViewWeek) {
return this.selectedDate
.clone()
.subtract(1, "week")
.isSameOrAfter(this.today, "week")
}
if (this.isViewDay) {
return this.selectedDate
.clone()
.subtract(1, "day")
.isSameOrAfter(this.today, "day")
}
return false
}
@computed
get canResetDate() {
if (this.isViewMonth) {
return this.selectedDate
.clone()
.startOf("month")
.isAfter(this.today, "month")
}
if (this.isViewWeek) {
return this.selectedDate
.clone()
.startOf("week")
.isAfter(this.today, "week")
}
// Getter should always return value
// if (this.isViewDay) {
return this.selectedDate.isAfter(this.today, "day")
}
@computed
get isViewMonth() {
return this.view === ViewModes.Month
}
@computed
get isViewWeek() {
return this.view === ViewModes.Week
}
@computed
get isViewDay() {
return this.view === ViewModes.Day
}
@action.bound
resetCurrentDate() {
if (!this.canResetDate) {
return
}
this.selectedDate = this.momentWithTZ()
}
@action.bound
changeToMonthView() {
this.view = ViewModes.Month
}
@action.bound
changeToWeekView() {
this.view = ViewModes.Week
}
@action.bound
changeToDayView() {
this.view = ViewModes.Day
}
@action.bound
goToDate(date: Moment, mode: ViewModes) {
this.view = mode
this.isSelectedDateSetByJump = false
this.selectedDate = date
}
@action.bound
goNext() {
this.isSelectedDateSetByJump = true
if (this.isViewDay) {
this.selectedDate = this.selectedDate.clone().add(1, "day").startOf("day")
} else if (this.isViewWeek) {
this.selectedDate = this.selectedDate
.clone()
.add(1, "week")
.startOf("week")
} else {
this.selectedDate = this.selectedDate
.clone()
.add(1, "month")
.startOf("month")
}
}
@action.bound
goPrev() {
this.isSelectedDateSetByJump = true
if (!this.canGoPrev) {
return
}
if (this.isViewDay) {
this.selectedDate = this.selectedDate
.clone()
.subtract(1, "day")
.startOf("day")
} else if (this.isViewWeek) {
const startOfWeek = this.selectedDate
.clone()
.subtract(1, "week")
.startOf("week")
this.selectedDate = startOfWeek.isBefore(this.today)
? this.today
: startOfWeek
} else {
const startOfMonth = this.selectedDate
.clone()
.subtract(1, "month")
.startOf("month")
this.selectedDate = startOfMonth.isBefore(this.today)
? this.today
: startOfMonth
}
}
//
//
// Week Times
@action.bound
addStartTime(startsAtMoment: Moment) {
const canAdd = this.canAddStartTime(startsAtMoment)
if (canAdd === true) {
this.startTimes.push({
id: `local-1-${startsAtMoment.valueOf().toString()}`,
startsAt: startsAtMoment.format(),
startsAtMoment,
})
console.log("[Calendar] [Store] #canAddStartTime", {
startTimes: this.startTimes,
})
} else if (canAdd !== false) {
const newStartsAtMoment = canAdd
const newStartTime: ICalendarStartTime = {
id: `local-2-${startsAtMoment.valueOf().toString()}`,
startsAt: newStartsAtMoment.format(),
startsAtMoment: newStartsAtMoment,
}
this.startTimes.push(newStartTime)
}
}
@action.bound
removeStartTime(startTime: ICalendarStartTime) {
const startTimeIndex = this.startTimes.findIndex((time) =>
startTime.startsAtMoment.isSame(time.startsAtMoment)
)
if (startTimeIndex !== undefined && startTimeIndex >= 0) {
this.startTimes.splice(startTimeIndex, 1)
}
}
weekDaysWithEvents(weekDays: Moment[]): IWeekEvents {
const weekWithEvents = weekDays.map((day) => {
const startOfDay = day.clone().startOf("day")
const endOfDay = day.clone().endOf("day")
const weekDayStartEndTimeRange = moment.range(
startOfDay as any,
endOfDay as any
)
const eventsTree = this.eventsTree
const events = []
for (let event of eventsTree.queryInterval(
weekDayStartEndTimeRange.start.clone().add(1, "second"),
weekDayStartEndTimeRange.end.clone().subtract(1, "second")
)) {
events.push(event.value)
}
const response = {
events,
weekDay: day,
weekDayStartEndTimeRange,
}
return response
})
console.log("[Calendar] #weekEvents", {
weekWithEvents,
weekDays,
})
return weekWithEvents
}
@action.bound
setHoverStartTime(startTimeMoment: Moment) {
console.log("[CalendarStore] #setHoverStartTime", {
startTimeMoment,
store: this,
})
this.hoverStartTimeRaw = startTimeMoment
}
@action.bound
unsetHoverStartTime(startTimeMoment: Moment) {
console.log("[CalendarStore] #unsetHoverStartTime", {
startTimeMoment,
store: this,
})
if (this.hoverStartTimeRaw === startTimeMoment) {
this.hoverStartTimeRaw = undefined
}
}
@action.bound
clearHoverStartTime() {
this.hoverStartTimeRaw = undefined
}
@action.bound
removeEvent(event: ICalendarEvent) {
console.log("[Calendar] [Store] #removeEvent", { event, store: this })
// return this.removeStartTime(event.range.start)
return this.removeStartTime(event.startTime)
}
@computed
get hoverEvent() {
if (!this.hoverStartTimeRaw) {
return undefined
}
return generateEventFromStartTime(
EventType.HOVER,
this.eventDurationInMinutes,
undefined,
this.hoverStartTimeRaw
)
}
weekDaysWithHoverEvent(weekDays: Moment[]): IWeekEvents | undefined {
const hoverEvent = this.hoverEvent
if (!hoverEvent) {
return undefined
}
const weekDaysWithEvents = this.weekDaysWithEvents(weekDays)
const weekWithEvents = weekDays.map((day, index) => {
const startOfDay = day.clone().startOf("day")
const endOfDay = day.clone().endOf("day")
const weekDayStartEndTimeRange = moment.range(
startOfDay as any,
endOfDay as any
)
const events = []
if (hoverEvent.range.overlaps(weekDayStartEndTimeRange)) {
// Let's check if we some events we overlap so we forbid
const overlappedEventsForDayOfWeek = weekDaysWithEvents[
index
].events.find((event) => {
return event.range.overlaps(hoverEvent.range)
})
if (!overlappedEventsForDayOfWeek) {
events.push(hoverEvent)
}
}
const response = {
events,
weekDay: day,
weekDayStartEndTimeRange,
}
return response
})
console.log("[Calendar] #weekDaysWithHoverEvent", {
weekWithEvents,
weekDays,
})
return weekWithEvents
}
@action.bound
canAddStartTime(startTimeMoment: Moment): boolean | Moment {
const possibleEvent = generateEventFromStartTime(
EventType.SLOT,
this.eventDurationInMinutes,
undefined,
startTimeMoment
// startTimeMoment
)
const startTimeSearch = possibleEvent.range.start.clone().startOf("day")
const endTimeSearch = possibleEvent.range.end.clone().endOf("day")
const eventsTree = this.eventsTree
const eventsNearPossibleEvent = []
for (let event of eventsTree.queryInterval(
startTimeSearch,
endTimeSearch
)) {
eventsNearPossibleEvent.push(event.value)
}
const overlapEvent: ICalendarEvent = eventsNearPossibleEvent.find(
(event) => {
return event.range.overlaps(possibleEvent.range)
}
)
console.log("[Calendar] #canAddStartTime", {
overlapEvent,
eventsNearPossibleEvent,
possibleEvent,
})
if (!overlapEvent) {
return true
}
const possibleStart = overlapEvent.range.start
const possibleRange = moment.range(
possibleStart.clone().subtract(this.eventDurationInMinutes, "minutes"),
possibleStart.clone().subtract(1, "second")
)
const overlapEvents = []
for (let event of eventsTree.queryInterval(
possibleRange.start,
possibleRange.end
)) {
overlapEvents.push(event)
}
if (!overlapEvents.length) {
return possibleRange.start
}
return false
}
eventsQueryForDays(days: Moment[], includeEventsDidStartYesterday: boolean) {
return days.reduce((acc, day) => {
const eventsTree = this.eventsTree
const eventsForDay = []
for (let event of eventsTree.queryInterval(
day.clone().startOf("day"),
day.clone().endOf("day")
)) {
const calendarEvent: ICalendarEvent = event.value
if (
(!includeEventsDidStartYesterday &&
calendarEvent.range.start.day() === day.day()) ||
includeEventsDidStartYesterday
) {
eventsForDay.push(calendarEvent)
}
}
return { ...acc, [day.valueOf()]: eventsForDay }
}, {})
}
}
import moize from "moize"
import IntervalTree from "interval-tree-type"
import moment, { Moment } from "~/utils/moment"
import {
EventType,
ICalendarEvent,
ISelectOption,
ICalendarStartTime,
} from "./constants"
export const generateDayTimeRange = moize.maxSize(20)((day: Moment) => {
return moment.range(
day.clone().startOf("day") as any, // startOfDay
day.clone().endOf("day") as any // endOfDay
)
})
export const generateWeekDays = moize.maxSize(20)((selectedDate: Moment) => {
const startDay = selectedDate.clone().startOf("week")
const endDay = selectedDate.clone().endOf("week")
let day = startDay
const weekDays: Moment[] = []
while (day <= endDay) {
weekDays.push(day)
day = day.clone().add(1, "d")
}
return weekDays
})
export const generateEventFromStartTime = (
type: EventType,
eventDurationInMinutes: number,
startTime?: ICalendarStartTime,
startTimeRaw?: Moment
): ICalendarEvent => {
const range = moment.range(
startTime ? startTime.startsAtMoment : startTimeRaw,
(startTime ? startTime.startsAtMoment : startTimeRaw)
.clone()
.add(eventDurationInMinutes, "minutes")
)
return {
startTime,
uid: range.toString(), // Since we do not allow overlapped events, otherwise this whould be UUID
type,
range,
}
}
/**
* TODO Generate an indexed interval tree. TODO!! THIS SHOULD BE CALLED ONLY ONCE PER MOUNT.
* @param intervals
*/
export const generateIntervalTree = (events?: ICalendarEvent[]) => {
console.log("[Calendar] [generateIntervalTree]", { events })
const valuesEqual = (a, b) => true
const intervalTree = new IntervalTree(valuesEqual)
if (events) {
for (let event of events) {
intervalTree.insert(event.range.start, event.range.end, event)
}
}
return intervalTree
}
export const findLabelForTimezoneName = moize.maxSize(20)(
(timezones: ISelectOption[], timezoneName: string) => {
if (!timezones) {
return
}
const timezone = timezones.find((tz) => tz.value === timezoneName)
if (timezone) {
return timezone.label
}
return timezoneName
}
)
import React from "react"
import styled from "styled-components"
import { lighten, desaturate, rgba } from "polished"
import { Observer } from "mobx-react"
import flatMap from "lodash/flatMap"
import { Responsive } from "~/components/Responsive"
import { Icon } from "~/components/base"
import { SmartTooltip } from "~/components/SmartTooltip"
import { Moment } from "~/utils/moment"
import { generateMonthDays, IMonthDays } from "./utils"
import { generateWeekDays } from "../../utils"
import {
HeaderDayWrapper,
DaysOfWeekGridWrapper,
ViewContentWrapper,
Item,
} from "../../styles"
import {
useCalendarState,
ICalendarEvent,
TOnMonthEventClickHandler,
TOnItemDayViewClickHandler,
ViewModes,
} from "../../constants"
const ItemDayTooltipContent = React.memo(
({
day,
events,
onMonthEventClickHandler,
}: {
day: Moment,
events: ICalendarEvent[],
onMonthEventClickHandler: TOnMonthEventClickHandler,
}) => {
console.log("[Calendar] [MonthView] [ItemDayTooltipContent]", {
ItemDayTooltipContent,
onMonthEventClickHandler,
day,
events,
})
return (
<ItemDayTooltipContentWrapper>
<ItemDayTooltipContentWrapperHeader>
{day.format("dddd MMM, D YYYY")}
</ItemDayTooltipContentWrapperHeader>
<ItemDayTooltipContentWrapperContent>
{events
.sort((eventA, eventB) => {
return (
(eventA?.startTime?.startsAtMoment?.valueOf?.() || 0) -
(eventB?.startTime?.startsAtMoment?.valueOf?.() || 0)
)
})
.map((event) => {
const isBooked = event?.startTime?.free === false
return (
<ItemDayTooltipEvent
data-is-booked={isBooked ? true : undefined}
onClick={onMonthEventClickHandler?.({ event, events, day })}
key={event.uid}
>
<Icon name="time-clock-circle" size="1rem" />
<ItemDayTooltipEventText>
<div>{`${event.range.start.format("hh:mma")} -
${event.range.end.format("hh:mma")}`}</div>
{isBooked && (
<div>
<small>{`(Booked)`}</small>
</div>
)}
</ItemDayTooltipEventText>
</ItemDayTooltipEvent>
)
})}
</ItemDayTooltipContentWrapperContent>
</ItemDayTooltipContentWrapper>
)
}
)
const MonthDayController = React.memo(
(props: {
onItemDayViewClickHandler?: TOnItemDayViewClickHandler,
onMonthEventClickHandler?: TOnMonthEventClickHandler,
hasEvents?: boolean,
isTodayOrAfter?: boolean,
day: Moment,
events?: ICalendarEvent[],
children: any,
today: Moment,
}) => {
const {
onMonthEventClickHandler,
onItemDayViewClickHandler: _onItemDayViewClickHandler,
day,
children,
events,
today,
hasEvents,
isTodayOrAfter,
...rest
} = props
const [STATE] = useCalendarState()
const onItemDayViewClickHandler = React.useCallback(
(isMobile?: boolean) => () => {
// onItemDayViewClickHandler should return true to jump to date
if (
(_onItemDayViewClickHandler &&
_onItemDayViewClickHandler({
day,
timezoneName: STATE.timezoneName.get(),
events,
today,
isTodayOrAfter,
}) === true) ||
!_onItemDayViewClickHandler
) {
STATE.goToDate(day, isMobile ? ViewModes.Day : ViewModes.Week)
}
},
[day, STATE.timezone]
)
const isToday = today && day.isSame(today, "day")
const isSameMonth = day.isSame(day.clone().subtract(1, "day"), "month")
const commonProps = {
"data-month-jump": !isSameMonth ? true : undefined,
"data-is-before-today": !isTodayOrAfter ? true : undefined,
"data-is-today": isToday ? true : undefined,
"data-has-events": hasEvents ? true : undefined,
"data-has-click-handler": _onItemDayViewClickHandler ? true : undefined,
"data-test-component": "calendar-month-day-wrapper",
}
if (hasEvents) {
return (
<Responsive>
{({ isMobile, isTouch }) => {
// Skip tooltip for touch
if (isTouch) {
return (
<MonthDayWrapper
onClick={onItemDayViewClickHandler(isMobile)}
{...commonProps}
>
{children}
</MonthDayWrapper>
)
}
return (
<SmartTooltip
showOn="hover"
width={230}
height={220}
tooltipContentElement={
<ItemDayTooltipContent
{...{ events, day, onMonthEventClickHandler }}
/>
}
>
{({ isVisible, onHoverStart, onHoverEnd }) => {
return (
<MonthDayWrapper
onMouseEnter={!isMobile ? onHoverStart : undefined}
onMouseLeave={!isMobile ? onHoverEnd : undefined}
onClick={onItemDayViewClickHandler(isMobile)}
{...{ isVisible }}
{...commonProps}
>
{children}
</MonthDayWrapper>
)
}}
</SmartTooltip>
)
}}
</Responsive>
)
}
return (
<Responsive>
{({ isMobile }) => {
return (
<MonthDayWrapper
{...rest}
{...commonProps}
onClick={onItemDayViewClickHandler(isMobile)}
{...{ children }}
/>
)
}}
</Responsive>
)
}
)
const MonthDay = React.memo(
(props: {
events?: ICalendarEvent[],
today: Moment,
day: Moment,
onMonthEventClickHandler: TOnMonthEventClickHandler,
onItemDayViewClickHandler: TOnItemDayViewClickHandler,
}) => {
const {
today,
day,
events,
onMonthEventClickHandler,
onItemDayViewClickHandler,
} = props
const isTodayOrAfter = today && day.isSameOrAfter(today, "day")
const hasEvents = isTodayOrAfter && events?.length > 0 // TODO Review API
return (
<Responsive>
{({ isMobile }) => {
return (
<MonthDayController
{...{
day,
onMonthEventClickHandler,
onItemDayViewClickHandler,
hasEvents,
isTodayOrAfter,
today,
}}
events={hasEvents ? events : undefined}
>
<MonthDayTextWrapper>{day.format("D")}</MonthDayTextWrapper>
{hasEvents && !isMobile && (
<MonthDayEventWrapper>
<MonthDayEventDotWrapper />
<MonthDayEventTextWrapper>
{events.length} {events?.length > 1 ? "Slots" : "Slot"}
</MonthDayEventTextWrapper>
</MonthDayEventWrapper>
)}
</MonthDayController>
)
}}
</Responsive>
)
}
)
const Header = React.memo(({ weekDays }: { weekDays: Moment[] }) => (
<HeaderWrapper>
{weekDays.map((dayName) => (
<HeaderDayWrapper key={dayName.valueOf()}>
<Responsive>
{({ isMobile }) => {
return isMobile ? dayName.format("ddd") : dayName.format("dddd")
}}
</Responsive>
</HeaderDayWrapper>
))}
</HeaderWrapper>
))
const MonthsDays = React.memo(
({
today,
monthDays,
eventsForMonth,
onItemDayViewClickHandler,
onMonthEventClickHandler,
}: {
eventsForMonth?: { [day: string]: ICalendarEvent[] },
today: Moment,
monthDays: IMonthDays[],
onMonthEventClickHandler?: TOnMonthEventClickHandler,
onItemDayViewClickHandler?: TOnItemDayViewClickHandler,
}) => {
return (
<MonthDaysScrollerWrapper>
<MonthDaysGridWrapper>
{monthDays.map((week) =>
week.days.map((day: Moment) => {
const eventsForDay = eventsForMonth[day.valueOf()]
return (
<MonthDay
key={day.valueOf()}
{...{
events: eventsForDay,
day,
today,
onMonthEventClickHandler,
onItemDayViewClickHandler,
}}
/>
)
})
)}
</MonthDaysGridWrapper>
</MonthDaysScrollerWrapper>
)
}
)
export const MonthView = React.memo(
(props: {
selectedDate: Moment,
onMonthEventClickHandler?: TOnMonthEventClickHandler,
onItemDayViewClickHandler?: TOnItemDayViewClickHandler,
}) => {
const {
selectedDate,
onMonthEventClickHandler,
onItemDayViewClickHandler,
} = props
const [STATE] = useCalendarState()
const monthDays = generateMonthDays(selectedDate)
const weekDays = generateWeekDays(selectedDate)
return (
<Observer>
{() => {
const today = STATE.today
const days = flatMap(monthDays, (monthDay) => monthDay.days)
const eventsForMonth = STATE.eventsQueryForDays(days, false)
console.log("[Calendar] [MonthView] [View] [MonthView]", {
today,
days,
eventsForMonth,
weekDays,
monthDays,
STATE,
})
return (
<MonthViewContentWrapper>
<Header {...{ weekDays }} />
<MonthsDays
{...{
today,
monthDays,
eventsForMonth,
onMonthEventClickHandler,
onItemDayViewClickHandler,
}}
/>
</MonthViewContentWrapper>
)
}}
</Observer>
)
}
)
const MonthDayTextWrapper = styled.div``
const MonthDayWrapper = styled(Item)`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: hsl(214, 8%, 63%);
border-top: 1px solid ${(p) => p.theme.colors.lightGraySuper};
border-left: 1px solid ${(p) => p.theme.colors.lightGraySuper};
font-size: 1.3rem;
background-color: white;
transition: background-color 0.15s ease-in-out;
& > * + * {
margin-top: 0.7rem;
}
&:nth-child(-n + 7) {
border-top: none; /* hide top border for the first 7 days */
}
&:nth-child(7n + 1) {
border-left: none;
}
&[data-month-jump] {
border-left: 3px solid ${(p) => p.theme.colors.lightGraySuper};
}
&[data-has-events] {
color: #111111;
}
&:not([data-has-click-handler]),
&[data-has-events] {
cursor: pointer;
background-color: ${(p) =>
p.isVisible ? "hsla(215, 50%, 97%, 1.0)" : undefined};
&:hover {
background-color: hsla(215, 50%, 98%, 1.0);
}
}
&[data-is-before-today] {
background-color: ${(p) =>
lighten(0.15, desaturate(0.3, rgba(60, 121, 203, 0.05)))};
}
&[data-is-today] {
/*background-color: rgba(60, 121, 203, 0.05);*/
${MonthDayTextWrapper} {
/*color: ${(p) => p.theme.colors.secondary}*/;
background-color: ${(p) => p.theme.colors.secondary};
color: white;
font-weight: 600;
/*border: 1.5px solid ${(p) => p.theme.colors.secondary};*/
width: 3rem;
height: 3rem;
border-radius: 50%;
overflow: hidden;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
}
${(p) => p.theme.mq.mobile} {
&[data-is-before-today] {
color: hsl(214, 8%, 83%);
}
padding-left: 0;
padding-right: 0;
background: white !important;
border-left: 0;
border-top: 0;
${MonthDayTextWrapper} {
font-size: 0.9375rem;
width: 2.0rem;
height: 2.0rem;
align-items: center;
justify-content: center;
display: flex;
}
&[data-month-jump] {
border-left: 0;
}
&[data-has-events] {
&:after {
margin-top: 0.25rem;
content: " ";
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: ${(p) => p.theme.colors.secondary};
}
}
&[data-is-today] {
${MonthDayTextWrapper} {
font-size: 0.9375rem;
width: 2.0rem;
height: 2.0rem;
}
}
}
}`
const ItemDayTooltipContentWrapper = styled.div`
display: flex;
flex-direction: column;
width: 100%;
`
const ItemDayTooltipContentWrapperHeader = styled.div`
font-weight: 600;
padding: 20px;
padding-bottom: 10px;
`
const ItemDayTooltipContentWrapperContent = styled.div`
display: flex;
flex-direction: column;
flex: 1;
overflow-y: auto;
padding: 20px;
padding-top: 5px;
font-size: 0.9375rem;
line-height: 1;
width: 100%;
`
const HeaderWrapper = styled(DaysOfWeekGridWrapper)`
height: 3rem;
border-bottom: 1px solid ${(p) => p.theme.colors.lightGraySuper};
`
const MonthDaysScrollerWrapper = styled.div`
flex: 1;
max-height: 100%;
overflow-y: auto;
position: relative;
`
const MonthDaysGridWrapper = styled(DaysOfWeekGridWrapper)`
flex: 1;
min-height: 100%;
${(p) => p.theme.mq.mobile} {
/*position: absolute; Fixes iPhone collapsed height */
left: 0;
right: 0;
top: 0;
bottom: 0;
}
`
const ItemDayTooltipEvent = styled.div`
width: 100%;
padding: 0.3rem 0;
display: flex;
align-items: center;
cursor: ${(p) => (p.onClick ? "pointer" : undefined)};
user-select: none;
transition: color 0.15s ease-in-out;
&:hover {
color: ${(p) => p.theme.colors.secondary};
}
& > * {
display: inline-flex;
}
& > * + * {
margin-left: 0.5rem;
}
&[data-is-booked] {
color: ${(p) => p.theme.colors.green};
font-weight: 600;
&:hover {
color: ${(p) => p.theme.colors.green};
}
}
`
const ItemDayTooltipEventText = styled.div`
display: flex;
flex-direction: column;
`
const MonthDayEventWrapper = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
& > * + * {
margin-top: 0.4rem;
}
`
const MonthDayEventDotWrapper = styled.div`
display: flex;
width: 10px;
height: 10px;
background-color: ${(p) => p.theme.colors.secondary};
border-radius: 50%;
overflow: hidden;
`
const MonthDayEventTextWrapper = styled.div`
display: flex;
text-align: center;
font-size: 0.875rem;
color: ${(p) => p.theme.colors.secondary};
`
const MonthViewContentWrapper = styled(ViewContentWrapper)`
overflow: hidden; /* fixes no-scrollbar in FF */
`
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment