feat(calendar): tap an empty time slot to start a new event

In the day, 3 days and week grids an event could only be started with a
long press, which few people find. Tapping an empty slot now marks it
with a "New event" block, and tapping that block opens the editor at
that hour; a second tap elsewhere moves the block. Long press still
opens the editor straight away. Tapping a day's header in the week or
3 days grid opens that day.

The editor also started events from a slot half an hour late, because
it rounded a time already on the hour up to the next half hour.
This commit is contained in:
Linus Rath
2026-10-10 22:59:35 +02:00
parent f53cee7ec9
commit b832ac0c49
3 changed files with 54 additions and 3 deletions
+2
View File
@@ -99,9 +99,11 @@ interface EventModalProps {
onClose: () => void;
}
// A time already on the hour or half hour (a tapped slot) stays as it is.
function nextHalfHour(d: Date): Date {
const date = new Date(d);
const min = date.getMinutes();
if (min % 30 === 0 && date.getSeconds() === 0 && date.getMilliseconds() === 0) return date;
const add = min < 30 ? 30 - min : 60 - min;
date.setMinutes(date.getMinutes() + add, 0, 0);
return date;
+50 -1
View File
@@ -289,6 +289,7 @@ function TimeGridScrollViewInner({
const handleLongPressAt = React.useCallback(
(day: Date, hour: number) => {
if (!onCreateAtTime) return;
setDraft(null);
// A display date (that hour in the calendar's zone); the editor turns
// it into the real instant when it saves (eventTimeFieldsToSave).
const date = new Date(day);
@@ -298,6 +299,14 @@ function TimeGridScrollViewInner({
[onCreateAtTime],
);
// A tap on an empty slot marks it with a "New event" block; tapping that
// block creates the event. Two taps, so a stray tap never opens the editor.
const [draft, setDraft] = React.useState<{ key: string; hour: number } | null>(null);
const handleTapAt = React.useCallback((day: Date, hour: number) => {
const key = dayKey(day);
setDraft((cur) => (cur && cur.key === key && cur.hour === hour ? null : { key, hour }));
}, []);
const todayKey = dayKey(displayNow());
const renderItem = React.useCallback(
({ item: day }: ListRenderItemInfo<Date>) => (
@@ -313,11 +322,13 @@ function TimeGridScrollViewInner({
styles={styles}
onSelectEvent={onSelectEvent}
onLongPressAt={onCreateAtTime ? handleLongPressAt : undefined}
onTapAt={onCreateAtTime ? handleTapAt : undefined}
draftHour={draft && draft.key === dayKey(day) ? draft.hour : -1}
/>
),
[
layoutsFor, colWidth, todayKey, nowMinutes, calendars, timeFormat, currentUserEmails, c, styles,
onSelectEvent, onCreateAtTime, handleLongPressAt,
onSelectEvent, onCreateAtTime, handleLongPressAt, handleTapAt, draft,
],
);
@@ -443,6 +454,7 @@ function TimeGridScrollViewInner({
data={days}
keyExtractor={dayKey}
renderItem={renderItem}
extraData={draft}
getItemLayout={getItemLayout}
initialScrollIndex={initialIndexRef.current.index}
// Days added at the start keep the columns on screen in place.
@@ -535,6 +547,8 @@ const DayColumn = React.memo(function DayColumn({
styles,
onSelectEvent,
onLongPressAt,
onTapAt,
draftHour,
}: {
day: Date;
layouts: TimedEventLayout[];
@@ -548,6 +562,9 @@ const DayColumn = React.memo(function DayColumn({
styles: GridStyles;
onSelectEvent?: (event: CalendarEvent) => void;
onLongPressAt?: (day: Date, hour: number) => void;
onTapAt?: (day: Date, hour: number) => void;
/** Hour of the "New event" block in this column, or -1. */
draftHour: number;
}) {
const { t } = useCalendarLocale();
const handleLongPress = React.useCallback(
@@ -556,9 +573,16 @@ const DayColumn = React.memo(function DayColumn({
},
[onLongPressAt, day],
);
const handlePress = React.useCallback(
(e: GestureResponderEvent) => {
onTapAt?.(day, hourAtOffset(e.nativeEvent.locationY, HOUR_HEIGHT));
},
[onTapAt, day],
);
return (
<Pressable
style={[styles.dayCol, { width }]}
onPress={onTapAt ? handlePress : undefined}
onLongPress={onLongPressAt ? handleLongPress : undefined}
>
{layouts.map(({ event, column, totalColumns, startMinutes, endMinutes, continuesBefore, continuesAfter }) => {
@@ -585,6 +609,18 @@ const DayColumn = React.memo(function DayColumn({
/>
);
})}
{draftHour >= 0 && (
<Pressable
style={[styles.draft, { top: draftHour * HOUR_HEIGHT, height: HOUR_HEIGHT - 1 }]}
onPress={() => onLongPressAt?.(day, draftHour)}
accessibilityRole="button"
accessibilityLabel={t('calendar.events.new_event', 'New event')}
>
<Text style={styles.draftText} numberOfLines={1}>
{t('calendar.events.new_event', 'New event')}
</Text>
</Pressable>
)}
{nowMinutes >= 0 && (
<View pointerEvents="none" style={[styles.nowLine, { top: (nowMinutes / 60) * HOUR_HEIGHT }]}>
<View style={styles.nowDot} />
@@ -684,5 +720,18 @@ function makeStyles(c: ThemePalette) {
backgroundColor: c.error,
},
nowBar: { flex: 1, height: 1, backgroundColor: c.error },
draft: {
position: 'absolute',
left: 1,
right: 2,
borderRadius: 2,
borderWidth: 1,
borderColor: c.primary,
backgroundColor: c.primaryBg,
paddingHorizontal: 4,
paddingVertical: 2,
zIndex: 2,
},
draftText: { fontSize: 12, lineHeight: 16, fontWeight: '500', color: c.text },
});
}
+2 -2
View File
@@ -1106,7 +1106,7 @@ export default function CalendarScreen() {
weekStartsOn={calendarFirstDayOfWeek}
timeFormat={calendarTimeFormat}
currentUserEmails={currentUserEmails}
onSelectDate={handleSelectDate}
onSelectDate={viewMode === 'day' ? handleSelectDate : openDay}
onSelectEvent={handleSelectEvent}
onCreateAtTime={openCreate}
/>
@@ -1121,7 +1121,7 @@ export default function CalendarScreen() {
weekStartsOn={calendarFirstDayOfWeek}
timeFormat={calendarTimeFormat}
currentUserEmails={currentUserEmails}
onSelectDate={handleSelectDate}
onSelectDate={openDay}
onSelectEvent={handleSelectEvent}
onCreateAtTime={openCreate}
/>