Last active
March 5, 2021 13:23
-
-
Save gastonmorixe/ea615171ce2dc294ea8228046e62827f to your computer and use it in GitHub Desktop.
Calendar with Timezones Support
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } | |
}, {}) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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