Skip to content

Commit 8018768

Browse files
wip(calendar): create daily display
1 parent d93d4cc commit 8018768

File tree

6 files changed

+340
-55
lines changed

6 files changed

+340
-55
lines changed

components/calendar/components.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import useTranslation from 'next-translate/useTranslation';
55

66
import { getDateWithDay, getDateWithTime } from 'lib/utils/time';
77

8-
import styles from './weekly-display.module.scss';
8+
import styles from './display.module.scss';
99
import { useCalendarState } from './state';
1010

1111
const COLS = Array(7).fill(null);
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
import {
2+
MouseEvent,
3+
UIEvent,
4+
memo,
5+
useCallback,
6+
useEffect,
7+
useMemo,
8+
useRef,
9+
useState,
10+
} from 'react';
11+
import { animated, useSpring } from 'react-spring';
12+
import cn from 'classnames';
13+
import mergeRefs from 'react-merge-refs';
14+
import { ResizeObserver as polyfill } from '@juggle/resize-observer';
15+
import useMeasure from 'react-use-measure';
16+
import useTranslation from 'next-translate/useTranslation';
17+
18+
import LoadingDots from 'components/loading-dots';
19+
20+
import { Callback } from 'lib/model/callback';
21+
import { Meeting } from 'lib/model/meeting';
22+
import { Position } from 'lib/model/position';
23+
import { useClickContext } from 'lib/hooks/click-outside';
24+
import { useOrg } from 'lib/context/org';
25+
import { useUser } from 'lib/context/user';
26+
27+
import { DialogPage, useCalendarState } from './state';
28+
import { Lines, Times } from './components';
29+
import { MouseEventHackData, MouseEventHackTarget } from './hack-types';
30+
import { config, width } from './spring-animation';
31+
import { expand, placeMeetingsInDay } from './place-meetings';
32+
import { getMeeting, getPosition } from './utils';
33+
import MeetingItem from './meetings/item';
34+
import MeetingRnd from './meetings/rnd';
35+
import styles from './display.module.scss';
36+
37+
export interface DailyDisplayProps {
38+
searching: boolean;
39+
meetings: Meeting[];
40+
filtersOpen: boolean;
41+
width: number;
42+
setWidth: Callback<number>;
43+
offset: Position;
44+
setOffset: Callback<Position>;
45+
}
46+
47+
function DailyDisplay({
48+
searching,
49+
meetings,
50+
filtersOpen,
51+
width: cellWidth,
52+
setWidth: setCellWidth,
53+
offset,
54+
setOffset,
55+
}: DailyDisplayProps): JSX.Element {
56+
const [cellsMeasureIsCorrect, setCellsMeasureIsCorrect] = useState(false);
57+
const [rowsMeasureRef, rowsMeasure] = useMeasure({ polyfill });
58+
const [cellsMeasureRef, cellsMeasure] = useMeasure({
59+
polyfill,
60+
scroll: true,
61+
});
62+
const [cellMeasureRef, cellMeasure] = useMeasure({ polyfill });
63+
64+
useEffect(() => {
65+
setCellWidth(cellMeasure.width);
66+
}, [setCellWidth, cellMeasure.width]);
67+
68+
// See: https://github.com/pmndrs/react-use-measure/issues/37
69+
// Current workaround is to listen for scrolls on the parent div. Once
70+
// the user scrolls, we know that the `rowsMeasure.x` is no longer correct
71+
// but that the `cellsMeasure.x` is correct.
72+
useEffect(() => {
73+
setOffset({
74+
x: cellsMeasureIsCorrect ? cellsMeasure.x : rowsMeasure.x + 8,
75+
y: cellsMeasure.y,
76+
});
77+
}, [
78+
setOffset,
79+
cellsMeasureIsCorrect,
80+
cellsMeasure.x,
81+
cellsMeasure.y,
82+
rowsMeasure.x,
83+
]);
84+
85+
useEffect(() => {
86+
setCellsMeasureIsCorrect(false);
87+
}, [filtersOpen]);
88+
89+
// Scroll to 8:30am by default (assumes 48px per hour).
90+
const rowsRef = useRef<HTMLDivElement>(null);
91+
useEffect(() => {
92+
if (rowsRef.current) rowsRef.current.scrollTop = 48 * 8 + 24;
93+
}, []);
94+
95+
const {
96+
rnd,
97+
setRnd,
98+
setEditing,
99+
dragging,
100+
setDialog,
101+
setDialogPage,
102+
start,
103+
} = useCalendarState();
104+
105+
const [eventTarget, setEventTarget] = useState<MouseEventHackTarget>();
106+
const [eventData, setEventData] = useState<MouseEventHackData>();
107+
108+
// Create a new `TimeslotRND` closest to the user's click position. Assumes
109+
// each column is 82px wide and every hour is 48px tall (i.e. 12px = 15min).
110+
const { user } = useUser();
111+
const { org } = useOrg();
112+
const onClick = useCallback(
113+
(event: MouseEvent) => {
114+
if (dragging) return;
115+
const pos = { x: event.clientX - offset.x, y: event.clientY - offset.y };
116+
const orgId = org ? org.id : user.orgs[0] || 'default';
117+
const creating = new Meeting({ id: 0, creator: user, org: orgId });
118+
setEventTarget(undefined);
119+
setEventData(undefined);
120+
setEditing(getMeeting(48, pos, creating, cellWidth, start));
121+
setDialogPage(DialogPage.Create);
122+
setDialog(true);
123+
setRnd(true);
124+
},
125+
[
126+
org,
127+
user,
128+
setEditing,
129+
setDialog,
130+
setDialogPage,
131+
setRnd,
132+
dragging,
133+
start,
134+
offset,
135+
cellWidth,
136+
]
137+
);
138+
139+
// Sync the scroll position of the main cell grid and the static headers. This
140+
// was inspired by the way that Google Calendar's UI is currently setup.
141+
// @see {@link https://mzl.la/35OIC9y}
142+
const headerRef = useRef<HTMLDivElement>(null);
143+
const timesRef = useRef<HTMLDivElement>(null);
144+
const ticking = useRef<boolean>(false);
145+
const onScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
146+
setCellsMeasureIsCorrect(true);
147+
const { scrollTop, scrollLeft } = event.currentTarget;
148+
if (!ticking.current) {
149+
requestAnimationFrame(() => {
150+
if (timesRef.current) timesRef.current.scrollTop = scrollTop;
151+
if (headerRef.current) headerRef.current.scrollLeft = scrollLeft;
152+
ticking.current = false;
153+
});
154+
ticking.current = true;
155+
}
156+
}, []);
157+
158+
const eventGroups = useMemo(() => placeMeetingsInDay(meetings, start.getDay()), [meetings, start]);
159+
const props = useSpring({ config, marginRight: filtersOpen ? width : 0 });
160+
161+
const [now, setNow] = useState<Date>(new Date());
162+
useEffect(() => {
163+
const tick = () => setNow(new Date());
164+
const intervalId = window.setInterval(tick, 60000);
165+
return () => window.clearInterval(intervalId);
166+
}, []);
167+
168+
const { updateEl, removeEl } = useClickContext();
169+
const cellsClickRef = useCallback(
170+
(node: HTMLElement | null) => {
171+
if (!node) return removeEl('calendar-cells');
172+
return updateEl('calendar-cells', node);
173+
},
174+
[updateEl, removeEl]
175+
);
176+
177+
const { lang: locale } = useTranslation();
178+
const today =
179+
now.getFullYear() === start.getFullYear() &&
180+
now.getMonth() === start.getMonth() &&
181+
now.getDate() === start.getDate();
182+
183+
// Show current time indicator if today is current date.
184+
const { y: top } = getPosition(now);
185+
186+
return (
187+
<animated.div className={styles.wrapper} style={props}>
188+
<div className={styles.headerWrapper}>
189+
<div ref={headerRef} className={styles.headerContent}>
190+
<div className={styles.headers}>
191+
<div className={styles.titleWrapper}>
192+
<h2 className={cn({ [styles.today]: today })}>
193+
<div className={styles.weekday}>
194+
{start.toLocaleString(locale, { weekday: 'short' })}
195+
</div>
196+
<div className={styles.date}>{start.getDate()}</div>
197+
</h2>
198+
</div>
199+
</div>
200+
<div className={styles.headerCells}>
201+
<div className={styles.headerCell} />
202+
</div>
203+
</div>
204+
<div className={styles.scroller} />
205+
</div>
206+
<div className={styles.gridWrapper}>
207+
<div className={styles.grid}>
208+
<div className={styles.timesWrapper} ref={timesRef}>
209+
<div className={styles.times}>
210+
<Times />
211+
<div className={styles.spacer} />
212+
</div>
213+
</div>
214+
<div
215+
className={styles.rowsWrapper}
216+
onScroll={onScroll}
217+
ref={mergeRefs([rowsMeasureRef, rowsRef])}
218+
>
219+
{searching && (
220+
<div className={styles.loader}>
221+
<LoadingDots size={4} />
222+
</div>
223+
)}
224+
<div className={styles.rows}>
225+
<div className={styles.lines}>
226+
<Lines />
227+
</div>
228+
<div className={styles.space} />
229+
<div
230+
className={styles.cells}
231+
onClick={onClick}
232+
ref={mergeRefs([cellsMeasureRef, cellsClickRef])}
233+
>
234+
{rnd && (
235+
<MeetingRnd
236+
now={now}
237+
width={cellWidth}
238+
eventData={eventData}
239+
eventTarget={eventTarget}
240+
/>
241+
)}
242+
<div className={styles.cell} ref={cellMeasureRef}>
243+
{today && (
244+
<div style={{ top }} className={styles.indicator}>
245+
<div className={styles.dot} />
246+
<div className={styles.line} />
247+
</div>
248+
)}
249+
{eventGroups
250+
.map((cols: Meeting[][]) =>
251+
cols.map((col: Meeting[], colIdx) =>
252+
col.map((e: Meeting) => (
253+
<MeetingItem
254+
now={now}
255+
meeting={e}
256+
setEventTarget={setEventTarget}
257+
setEventData={setEventData}
258+
widthPercent={
259+
expand(e, colIdx, cols) / cols.length
260+
}
261+
leftPercent={colIdx / cols.length}
262+
key={e.id}
263+
/>
264+
))
265+
)
266+
)
267+
.flat(2)}
268+
<style jsx>{`
269+
div {
270+
max-width: calc(100% - 10px) !important;
271+
}
272+
`}</style>
273+
</div>
274+
</div>
275+
</div>
276+
</div>
277+
</div>
278+
</div>
279+
</animated.div>
280+
);
281+
}
282+
283+
export default memo(DailyDisplay);

components/calendar/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import FiltersSheet from './filters-sheet';
3434
import Header from './header';
3535
import RecurDialog from './recur-dialog';
3636
import SearchBar from './search-bar';
37-
import WeeklyDisplay from './weekly-display';
37+
import DailyDisplay from './daily-display';
3838
import styles from './calendar.module.scss';
3939

4040
const initialEditData = new Meeting();
@@ -360,7 +360,7 @@ export default function Calendar({
360360
byOrg={byOrg}
361361
/>
362362
<div className={styles.content}>
363-
<WeeklyDisplay
363+
<DailyDisplay
364364
searching={!data}
365365
meetings={meetings}
366366
filtersOpen={filtersOpen}

0 commit comments

Comments
 (0)