Files
margin-calendar/src/components/GridView.tsx
T

786 lines
28 KiB
TypeScript

// Week and day. Day is week with one column: same fit, same packing, same everything, so there is
// one component and a day count rather than two views that drift apart.
//
// The grid never scrolls. The body is measured with a ResizeObserver and handed to `computeFit`,
// which solves the row height for whatever is left after the day bar and the all-day band, so
// resizing the window rescales the day instead of growing a scrollbar. The one exception is the
// escape hatch: when the row height hits its floor the fit says `overflow` and the body scrolls.
//
// A finger and a mouse mean different things by the same press. A mouse press on empty grid can
// only be the start of a drag, because a mouse has nothing else to do here; a finger press is
// ambiguous until it either stays put or travels, so the grid waits it out. Everything that
// follows from that is in `begin`, `arm` and `onPointerMove`.
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
import { eventCreate, eventUpdate } from "../api/events";
import { computeBounds, computeFit, timeToY, yToTime, type Bounds, type Fold } from "../grid/fit";
import { loadBounds, saveBounds } from "../grid/folds";
import { useCalendarView } from "../store/useCalendarView";
import { notify } from "../store/useToast";
import {
MINUTES_PER_DAY,
addDays,
dayName,
startOfDay,
weekAnchor,
toOffsetIso,
today,
} from "../time";
import { useHourStart } from "../useClock";
import { PHONE_QUERY, useMediaQuery } from "../useMedia";
import "../styles/grid.css";
import "../styles/folds.css";
import { GridAllDay } from "./GridAllDay";
import { GridDay } from "./GridDay";
import { GridDraft } from "./GridDraft";
import { GridGaps } from "./GridGaps";
import { GridGhost } from "./GridGhost";
import {
MIN_EVENT_MINUTES,
clampMinutes,
dayMinutes,
heldHours,
hourLabel,
inBand,
isVisible,
keyId,
keyOf,
minutesToMs,
snapMinutes,
vars,
type Placed,
type Times,
} from "./GridModel";
import { GridNowLine } from "./GridNowLine";
import { GridStrip } from "./GridStrip";
import { useGrid, type Drag, type DragMode } from "./GridStore";
/** Half hour rules stop earning their ink below this row height. */
const HALF_RULE_MIN_ROW = 34;
/** Pointer travel before a press becomes a drag rather than a click. */
const DRAG_SLOP = 3;
/**
* The same for a finger, which cannot hold to three pixels: a still thumb wanders further than
* that, and every one of those pixels used to be read as the start of a drag.
*/
const TOUCH_SLOP = 12;
/**
* How long a finger has to stay down before the grid takes the gesture as a drag.
*
* A press is the one thing a touchscreen cannot spend freely. It is how you tap, how you swipe and
* how you drag, and the grid cannot tell which it is watching until the finger either travels or
* stays. Long enough that a swipe is never mistaken for a create, short enough that holding still
* feels answered rather than ignored.
*/
const LONG_PRESS_MS = 350;
/**
* Sideways travel that pages the view instead. Deliberately well past `TOUCH_SLOP`, so the drift
* in a press that meant to stay put cannot turn into navigation.
*/
const SWIPE_MIN = 60;
/**
* What a long press on empty grid drags out on its own, so a press that never travels still leaves
* something behind. Mirrors `NEW_MINUTES` in keys/commands.ts: `c` and a long press are the same
* act with a different limb.
*/
const TOUCH_CREATE_MINUTES = 60;
/**
* Whether this press is a finger, which is a question about the gesture rather than about the
* device. Read off the event rather than off `(pointer: coarse)` on purpose: a tablet with a
* trackpad attached reports a coarse primary pointer all day and still wants the mouse rules for
* whatever is actually in your hand.
*/
const isCoarse = (e: ReactPointerEvent): boolean =>
e.pointerType === "touch" || e.pointerType === "pen";
interface Override extends Times {
/** The write came back; the next batch of instances is allowed to take over. */
settled: boolean;
}
interface Gesture {
pointerId: number;
mode: DragMode;
item: Placed | null;
colsLeft: number;
colsWidth: number;
canvasTop: number;
downX: number;
downY: number;
originDay: number;
originMin: number;
baseStart: number;
baseEnd: number;
/** The pointer has travelled past the slop, so the drag follows it from here on. */
moved: boolean;
coarse: boolean;
/** A finger that is down but has not earned the drag yet: still a tap, a swipe or a long press. */
pending: boolean;
/** The long press, cancelled the moment the press turns into anything else. */
timer: number | null;
}
export interface GridViewProps {
/** Where a dragged-out event lands. Falls back to the calendar most of the span already uses. */
defaultCalendarId?: string;
}
export function GridView({ defaultCalendarId }: GridViewProps) {
const view = useCalendarView((s) => s.view);
const anchor = useCalendarView((s) => s.anchor);
const instances = useCalendarView((s) => s.instances);
const selected = useCalendarView((s) => s.selected);
const select = useCalendarView((s) => s.select);
const moveDay = useCalendarView((s) => s.moveDay);
const setView = useCalendarView((s) => s.setView);
const setAnchor = useCalendarView((s) => s.setAnchor);
// Layout only. Which gesture rules apply is decided per press, off the pointer itself.
const phone = useMediaQuery(PHONE_QUERY);
const folds = useGrid((s) => s.folds);
const floor = useGrid((s) => s.floor);
const dragId = useGrid((s) => s.drag?.id ?? null);
const bodyRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLDivElement | null>(null);
const colsRef = useRef<HTMLDivElement | null>(null);
const gesture = useRef<Gesture | null>(null);
const hoverRef = useRef<number | null>(null);
const previous = useRef<Bounds>(loadBounds());
const saved = useRef("");
const [viewportH, setViewportH] = useState(0);
const [overrides, setOverrides] = useState<Record<string, Override>>({});
// The window is the only input to the row height, so the body measures itself and nothing else
// in the tree gets a say. Measured before paint, so the first frame is already the right shape.
//
// This is a callback ref rather than an effect on mount. Switching to the agenda unmounts this
// body and switching back mounts a different element, so an effect with an empty dependency list
// would keep observing the discarded node and solve the axis for a stale height for the rest of
// the session. The ref fires for every element that actually takes the role.
const observerRef = useRef<ResizeObserver | null>(null);
const setBody = useCallback((el: HTMLDivElement | null) => {
observerRef.current?.disconnect();
observerRef.current = null;
bodyRef.current = el;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const h = entries[entries.length - 1].contentRect.height;
setViewportH((prev) => (Math.abs(prev - h) < 0.5 ? prev : h));
});
observer.observe(el);
observerRef.current = observer;
setViewportH(el.clientHeight);
}, []);
// An optimistic move survives until the instance that replaces it arrives.
useEffect(() => {
setOverrides((prev) => {
const next: Record<string, Override> = {};
let dropped = false;
for (const [id, value] of Object.entries(prev)) {
if (value.settled) dropped = true;
else next[id] = value;
}
return dropped ? next : prev;
});
}, [instances]);
const days = useMemo(() => {
const count = view === "day" ? 1 : 7;
const first = count === 1 ? startOfDay(anchor) : weekAnchor(anchor);
return Array.from({ length: count }, (_, i) => addDays(first, i));
}, [view, anchor]);
const banded = useMemo(
() => instances.filter((i) => isVisible(i) && inBand(i)),
[instances],
);
/** Timed, single day, inside the span, with any optimistic move already applied. */
const byDay = useMemo(() => {
const buckets = new Map<number, Placed[]>();
for (const day of days) buckets.set(day, []);
for (const instance of instances) {
if (!isVisible(instance)) continue;
const id = keyId(keyOf(instance));
const override = overrides[id];
const times: Times = override
? { startMs: override.startMs, endMs: override.endMs }
: { startMs: instance.startMs, endMs: instance.endMs };
if (inBand(instance, times)) continue;
const bucket = buckets.get(startOfDay(times.startMs));
if (bucket) bucket.push({ instance, id, ...times });
}
return days.map((day) => buckets.get(day) as Placed[]);
}, [instances, days, overrides]);
const timed = useMemo(() => byDay.flat(), [byDay]);
// The hour the clock is in, when today is one of the columns. Keyed on the hour rather than the
// minute, because that is how often the answer changes. The line itself moves every minute, on
// its own tick, in `GridNowLine`.
const hourStart = useHourStart();
const nowHour = useMemo(
() => (days.includes(startOfDay(hourStart)) ? new Date(hourStart).getHours() : null),
[days, hourStart],
);
// The hours nothing may hide: every hour an event on screen covers, and the hour it is now, so
// the now line always has a row to land in. They are carved out of every strip alike, the ones
// outside the bounds and the ones you folded, and the bounds themselves stay the events' business.
const held = useMemo(() => heldHours(timed, nowHour), [timed, nowHour]);
// Hysteresis needs the bounds it adopted last pass, so they live in a ref rather than in state:
// paging a week must not reflow the axis, and a render loop through state would fight that.
const layout = useMemo(() => {
const auto = computeBounds(timed, previous.current);
const bounds = floor
? { start: Math.min(auto.start, floor.start), end: Math.max(auto.end, floor.end) }
: auto;
previous.current = bounds;
return computeFit({ bounds, folds, hold: held, viewportHeight: viewportH });
}, [timed, folds, floor, viewportH, held]);
useEffect(() => {
const signature = `${layout.bounds.start} ${layout.bounds.end}`;
if (signature === saved.current) return;
saved.current = signature;
saveBounds(layout.bounds);
}, [layout.bounds]);
const publish = useGrid((s) => s.publish);
useEffect(() => {
if (viewportH > 0) publish(layout, days, held);
}, [publish, layout, days, held, viewportH]);
const todayMs = today();
const todayIndex = days.findIndex((d) => d === todayMs);
const rules = useMemo(() => {
const out: { key: string; y: number; half: boolean }[] = [];
const half = layout.rowHeight >= HALF_RULE_MIN_ROW;
for (const segment of layout.segments) {
if (segment.kind !== "hours") continue;
for (let m = segment.start; m < segment.end; m += 60) {
out.push({ key: `h${m}`, y: timeToY(layout, m), half: false });
if (half && m + 30 < segment.end) {
out.push({ key: `m${m}`, y: timeToY(layout, m + 30), half: true });
}
}
}
return out;
}, [layout]);
const labels = useMemo(() => {
const out: { hour: number; y: number }[] = [];
for (const segment of layout.segments) {
if (segment.kind !== "hours") continue;
for (let m = segment.start; m < segment.end; m += 60) {
out.push({ hour: m / 60, y: timeToY(layout, m) });
}
}
return out;
}, [layout]);
const unfold = useGrid((s) => s.unfold);
const onUnfold = useCallback((range: Fold) => unfold(range), [unfold]);
const columnAt = (clientX: number, left: number, width: number): number => {
const index = Math.floor(((clientX - left) / Math.max(width, 1)) * days.length);
return Math.max(0, Math.min(days.length - 1, index));
};
/**
* A fold is not somewhere you can drop things. `yToTime` reads a pixel inside a strip as the
* first minute it hides, which is right for reading and wrong for dragging: pulling an edge one
* hour up into the leading strip would jump it to midnight. So a drag that reaches a fold stops
* at the near side of it, judged from where the gesture started.
*/
const minuteAt = (clientY: number, canvasTop: number, anchor = 0): number => {
const raw = yToTime(layout, clientY - canvasTop);
const segment = layout.segments.find((s) => raw >= s.start && raw < s.end);
if (segment?.kind === "strip") return segment.start >= anchor ? segment.start : segment.end;
return snapMinutes(raw);
};
const clearPress = (g: Gesture) => {
if (g.timer === null) return;
clearTimeout(g.timer);
g.timer = null;
};
/** Drop the gesture without committing anything: a swipe, a cancel, an unmount. */
const abandon = (pointerId: number) => {
const g = gesture.current;
if (g) clearPress(g);
gesture.current = null;
try {
canvasRef.current?.releasePointerCapture(pointerId);
} catch {
/* already released */
}
useGrid.getState().setDrag(null);
};
/**
* The long press landed, so the press becomes the drag a mouse would have started on the way
* down. It arrives already `moved`, which is what lets a press that never travels leave a draft
* behind: a finger that has held still for a third of a second has said what it wants, and
* asking it to also drag out a duration before anything happens is asking twice.
*
* `g.moved` stays false, so the slop below still applies. Without that the hour this opens with
* would collapse to fifteen minutes on the first tremor.
*/
const arm = (g: Gesture) => {
if (gesture.current !== g) return;
g.pending = false;
g.timer = null;
const end =
g.mode === "create" ? Math.min(MINUTES_PER_DAY, g.baseStart + TOUCH_CREATE_MINUTES) : g.baseEnd;
useGrid.getState().setDrag({
mode: g.mode,
id: g.item?.id ?? null,
dayIndex: g.originDay,
startMin: g.baseStart,
endMin: end,
moved: true,
});
};
const begin = (
e: ReactPointerEvent,
mode: DragMode,
item: Placed | null,
dayIndex: number,
baseStart: number,
baseEnd: number,
) => {
const canvas = canvasRef.current;
const cols = colsRef.current;
if (!canvas || !cols) return;
const colsRect = cols.getBoundingClientRect();
const canvasTop = canvas.getBoundingClientRect().top;
const coarse = isCoarse(e);
const g: Gesture = {
pointerId: e.pointerId,
mode,
item,
colsLeft: colsRect.left,
colsWidth: colsRect.width,
canvasTop,
downX: e.clientX,
downY: e.clientY,
originDay: dayIndex,
originMin: minuteAt(e.clientY, canvasTop),
baseStart,
baseEnd,
moved: false,
coarse,
pending: coarse,
timer: null,
};
gesture.current = g;
// A pointer that has already gone throws here, and a gesture is not worth a broken render.
try {
canvas.setPointerCapture(e.pointerId);
} catch {
/* the gesture still works, it just stops at the window edge */
}
if (coarse) {
g.timer = window.setTimeout(() => arm(g), LONG_PRESS_MS);
return;
}
useGrid.getState().setDrag({
mode,
id: item?.id ?? null,
dayIndex,
startMin: baseStart,
endMin: baseEnd,
moved: false,
});
};
// A long press outliving its own grid would arm a drag into a component that is no longer there.
useEffect(
() => () => {
if (gesture.current) clearPress(gesture.current);
},
[],
);
const onPointerDownBlock = useCallback(
(e: ReactPointerEvent, item: Placed, mode: DragMode) => {
if (e.button !== 0 || gesture.current) return;
e.stopPropagation();
select(keyOf(item.instance));
const dayStart = startOfDay(item.startMs);
const index = days.findIndex((d) => d === dayStart);
if (index === -1) return;
const { startMin, endMin } = dayMinutes(item, dayStart);
// A block nothing can be done to is still something a swipe has to travel through. The press
// is stopped here rather than on the canvas, so without this the page turn was dead over
// every read-only event, which on a day with a couple of meetings marked busy is most of the
// column. It gets a gesture with no long press behind it: the only thing it can become is
// the swipe, and there is no item on it to commit a move to.
const inert = item.instance.readOnly || useGrid.getState().draft !== null;
if (inert && !isCoarse(e)) return;
begin(e, mode, inert ? null : item, index, startMin, endMin);
if (inert && gesture.current) clearPress(gesture.current);
},
// `begin` closes over the current layout and days, which is what a fresh gesture wants.
[days, layout, select],
);
const onPointerDownCanvas = (e: ReactPointerEvent) => {
if (e.button !== 0 || gesture.current) return;
if (e.target instanceof Element && e.target.closest("[data-no-drag]")) return;
const cols = colsRef.current;
if (!cols) return;
const rect = cols.getBoundingClientRect();
if (e.clientX < rect.left) return;
if (useGrid.getState().draft) {
useGrid.getState().setDraft(null);
return;
}
// Everything past this point is the grid's gesture, so the browser is told not to follow it up
// with the mouse events it sends after a touch. They arrive once the gesture is over and land
// wherever the finger happens to be: on the create card that has just opened under it, whose
// input they blur before a single letter can be typed, or on an event on the day a swipe has
// just paged to. The presses that are still a click, on a block, a strip or a chip, have all
// returned above.
if (isCoarse(e)) e.preventDefault();
const canvasTop = canvasRef.current?.getBoundingClientRect().top ?? 0;
const minutes = minuteAt(e.clientY, canvasTop);
begin(e, "create", null, columnAt(e.clientX, rect.left, rect.width), minutes, minutes);
};
const onPointerMove = (e: ReactPointerEvent) => {
const g = gesture.current;
if (!g) {
const canvasTop = canvasRef.current?.getBoundingClientRect().top ?? 0;
const hour = Math.floor(yToTime(layout, e.clientY - canvasTop) / 60);
if (hour !== hoverRef.current) {
hoverRef.current = hour;
useGrid.getState().setHoverHour(hour);
}
return;
}
if (e.pointerId !== g.pointerId) return;
const dx = e.clientX - g.downX;
const dy = e.clientY - g.downY;
// A finger that is still deciding. Sideways and far enough is the page turn; anything else
// that travels is at least not a long press, so the timer goes and the gesture lives on as a
// swipe candidate until the finger comes up.
if (g.pending) {
if (Math.abs(dx) > SWIPE_MIN && Math.abs(dx) > Math.abs(dy)) {
abandon(g.pointerId);
// One day, whichever view is up. A week that jumped a week per swipe would be a week you
// could never land on the day you meant.
moveDay(dx > 0 ? -1 : 1);
return;
}
if (Math.abs(dx) > TOUCH_SLOP || Math.abs(dy) > TOUCH_SLOP) clearPress(g);
return;
}
if (!g.moved) {
const slop = g.coarse ? TOUCH_SLOP : DRAG_SLOP;
if (Math.abs(dx) < slop && Math.abs(dy) < slop) return;
g.moved = true;
}
const minutes = minuteAt(e.clientY, g.canvasTop, g.originMin);
const column = columnAt(e.clientX, g.colsLeft, g.colsWidth);
let next: Drag;
if (g.mode === "create") {
const start = Math.min(g.originMin, minutes);
const end = Math.max(g.originMin, minutes);
next = {
mode: g.mode,
id: null,
dayIndex: g.originDay,
startMin: start,
endMin: Math.max(end, start + MIN_EVENT_MINUTES),
moved: true,
};
} else if (g.mode === "move") {
const duration = g.baseEnd - g.baseStart;
const start = Math.min(clampMinutes(g.baseStart + minutes - g.originMin), MINUTES_PER_DAY - duration);
next = {
mode: g.mode,
id: g.item?.id ?? null,
dayIndex: column,
startMin: start,
endMin: start + duration,
moved: true,
};
} else if (g.mode === "resize-start") {
next = {
mode: g.mode,
id: g.item?.id ?? null,
dayIndex: g.originDay,
startMin: Math.min(minutes, g.baseEnd - MIN_EVENT_MINUTES),
endMin: g.baseEnd,
moved: true,
};
} else {
next = {
mode: g.mode,
id: g.item?.id ?? null,
dayIndex: g.originDay,
startMin: g.baseStart,
endMin: Math.max(minutes, g.baseStart + MIN_EVENT_MINUTES),
moved: true,
};
}
useGrid.getState().setDrag(next);
};
const onPointerUp = (e: ReactPointerEvent) => {
const g = gesture.current;
if (!g || e.pointerId !== g.pointerId) return;
clearPress(g);
gesture.current = null;
try {
canvasRef.current?.releasePointerCapture(e.pointerId);
} catch {
/* already released */
}
// A press that came up before the long press landed has no drag behind it, so this falls
// through to the tap: deselect on empty grid, and the block's own handler opens its card.
const drag = useGrid.getState().drag;
useGrid.getState().setDrag(null);
if (!drag || !drag.moved) {
if (g.mode === "create") select(null);
return;
}
if (g.mode === "create") {
useGrid.getState().setDraft({
dayIndex: drag.dayIndex,
startMin: drag.startMin,
endMin: drag.endMin,
});
return;
}
if (g.item) void commitMove(g.item, drag);
};
const onPointerCancel = (e: ReactPointerEvent) => {
if (!gesture.current || e.pointerId !== gesture.current.pointerId) return;
abandon(e.pointerId);
};
async function commitMove(item: Placed, drag: Drag) {
const dayStart = days[drag.dayIndex];
if (dayStart === undefined) return;
const startMs = minutesToMs(dayStart, drag.startMin);
const endMs = minutesToMs(dayStart, drag.endMin);
if (startMs === item.startMs && endMs === item.endMs) return;
setOverrides((prev) => ({ ...prev, [item.id]: { startMs, endMs, settled: false } }));
try {
await eventUpdate(
keyOf(item.instance),
{ start: toOffsetIso(startMs), end: toOffsetIso(endMs) },
"this",
);
setOverrides((prev) => ({ ...prev, [item.id]: { startMs, endMs, settled: true } }));
void useCalendarView.getState().load();
} catch (error) {
setOverrides((prev) => {
const next = { ...prev };
delete next[item.id];
return next;
});
notify(String(error));
}
}
const fallbackCalendar = useMemo(() => {
const tally = new Map<string, number>();
for (const i of instances) {
if (i.readOnly) continue;
tally.set(i.calendarId, (tally.get(i.calendarId) ?? 0) + 1);
}
let best: string | undefined;
let most = 0;
for (const [id, n] of tally) {
if (n > most) {
most = n;
best = id;
}
}
return best;
}, [instances]);
async function commitDraft(title: string) {
const draft = useGrid.getState().draft;
if (!draft) return;
const dayStart = days[draft.dayIndex];
const calendarId = defaultCalendarId ?? fallbackCalendar;
if (dayStart === undefined || !calendarId) {
useGrid.getState().setDraft(null);
notify("No calendar to create in yet.");
return;
}
useGrid.getState().setSaving(true);
try {
await eventCreate({
calendarId,
summary: title || "Untitled",
start: toOffsetIso(minutesToMs(dayStart, draft.startMin)),
end: toOffsetIso(minutesToMs(dayStart, draft.endMin)),
allDay: false,
});
useGrid.getState().setDraft(null);
void useCalendarView.getState().load();
} catch (error) {
notify(String(error));
} finally {
useGrid.getState().setSaving(false);
}
}
const cancelDraft = useCallback(() => useGrid.getState().setDraft(null), []);
/**
* The way out of a week you cannot read. Seven columns in a phone's width is an overview and
* nothing more, so the day bar, which is the one part of the grid that stays a comfortable size,
* doubles as the control that opens the column you tapped.
*
* `setView` first: it re-parks the anchor on entry, and the load that lands should be the day's.
*/
const openDay = useCallback(
(day: number) => {
setView("day");
setAnchor(day);
},
[setView, setAnchor],
);
if (view === "agenda") return null;
return (
<div
className="grid"
data-view={view}
data-overflow={layout.overflow || undefined}
style={vars({ "--days": days.length, "--row-h": `${layout.rowHeight}px` })}
>
<div className="grid-head">
<div className="grid-head-gutter" />
<div className="grid-head-days">
{days.map((day, i) => (
<button
type="button"
key={day}
className="grid-head-day"
title={`Open ${dayName(day)} ${new Date(day).getDate()}`}
data-today={day === todayMs || undefined}
data-past={day < todayMs || undefined}
data-index={i}
onClick={() => openDay(day)}
>
{/* A phone week gives a column fifty pixels. "Wed" in fifty pixels next to its date
is two things fighting, and the date is the one you navigate by. */}
<span className="grid-head-name">
{phone && view === "week" ? dayName(day).charAt(0) : dayName(day)}
</span>
<span className="grid-head-date">{new Date(day).getDate()}</span>
</button>
))}
</div>
</div>
<GridAllDay days={days} instances={banded} selected={selected} onSelect={select} />
<div className="grid-body" ref={setBody}>
<div
className="grid-canvas"
ref={canvasRef}
style={{ height: `${layout.totalHeight}px` }}
onPointerDown={onPointerDownCanvas}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
onPointerLeave={() => {
if (!gesture.current) {
hoverRef.current = null;
useGrid.getState().setHoverHour(null);
}
}}
>
<div className="grid-gutter">
{labels.map((label) => (
<span key={label.hour} className="grid-gutter-label" style={{ top: `${label.y}px` }}>
{hourLabel(label.hour)}
</span>
))}
</div>
<div className="grid-rules">
{rules.map((rule) => (
<span
key={rule.key}
className="grid-rule"
data-half={rule.half || undefined}
style={{ top: `${rule.y}px` }}
/>
))}
</div>
{layout.segments.map((segment) =>
segment.kind === "strip" ? (
<GridStrip key={`s${segment.start}`} segment={segment} onUnfold={onUnfold} />
) : null,
)}
<GridGaps />
<div className="grid-cols" ref={colsRef}>
{days.map((day, i) => (
<GridDay
key={day}
layout={layout}
dayStart={day}
index={i}
items={byDay[i]}
selected={selected}
dragId={dragId}
past={day < todayMs}
today={day === todayMs}
onPointerDownBlock={onPointerDownBlock}
/>
))}
{todayIndex !== -1 && (
<GridNowLine layout={layout} dayStart={days[todayIndex]} dayIndex={todayIndex} />
)}
<GridGhost />
<GridDraft onCommit={commitDraft} onCancel={cancelDraft} />
</div>
</div>
</div>
</div>
);
}
export default GridView;