From b832ac0c49b3121e80acd2f52d9b92c042d23f44 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:59:35 +0200 Subject: [PATCH] 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. --- src/components/calendar/EventModal.tsx | 2 + .../calendar/TimeGridScrollView.tsx | 51 ++++++++++++++++++- src/screens/CalendarScreen.tsx | 4 +- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index 9ab782b4..f0f25b6a 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -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; diff --git a/src/components/calendar/TimeGridScrollView.tsx b/src/components/calendar/TimeGridScrollView.tsx index ef3d3972..19aa4d44 100644 --- a/src/components/calendar/TimeGridScrollView.tsx +++ b/src/components/calendar/TimeGridScrollView.tsx @@ -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) => ( @@ -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 ( {layouts.map(({ event, column, totalColumns, startMinutes, endMinutes, continuesBefore, continuesAfter }) => { @@ -585,6 +609,18 @@ const DayColumn = React.memo(function DayColumn({ /> ); })} + {draftHour >= 0 && ( + onLongPressAt?.(day, draftHour)} + accessibilityRole="button" + accessibilityLabel={t('calendar.events.new_event', 'New event')} + > + + {t('calendar.events.new_event', 'New event')} + + + )} {nowMinutes >= 0 && ( @@ -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 }, }); } diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 57e2e290..02155bae 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -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} />