From aa154149be5345d0b1c2714d14c3a9682c0e7914 Mon Sep 17 00:00:00 2001 From: wmcparla <183443443+wmcparla@users.noreply.github.com> Date: Sat, 14 Nov 2026 07:12:42 -0500 Subject: [PATCH] fix: start a new event from the "+" button on a day the calendar shows #139 --- src/components/calendar/MonthScrollView.tsx | 17 ++- src/lib/__tests__/calendar-new-event.test.ts | 111 +++++++++++++++++++ src/lib/calendar-new-event.ts | 80 +++++++++++++ src/screens/CalendarScreen.tsx | 27 ++++- 4 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 src/lib/__tests__/calendar-new-event.test.ts create mode 100644 src/lib/calendar-new-event.ts diff --git a/src/components/calendar/MonthScrollView.tsx b/src/components/calendar/MonthScrollView.tsx index af033c868..4511a4101 100644 --- a/src/components/calendar/MonthScrollView.tsx +++ b/src/components/calendar/MonthScrollView.tsx @@ -62,6 +62,8 @@ interface MonthScrollViewProps { onExtendEnd?: () => void; /** Reports a day of the month in view whenever that month changes. */ onVisibleDateChange?: (date: Date) => void; + /** Reports the rows in view, first day to last, as the top row changes. */ + onVisibleRowsChange?: (rows: DayRange) => void; onSelectDate: (date: Date) => void; onLongPressDate?: (date: Date) => void; } @@ -90,6 +92,7 @@ function MonthScrollViewInner({ onExtendStart, onExtendEnd, onVisibleDateChange, + onVisibleRowsChange, onSelectDate, onLongPressDate, }: MonthScrollViewProps) { @@ -143,6 +146,9 @@ function MonthScrollViewInner({ } const onVisibleRef = React.useRef(onVisibleDateChange); onVisibleRef.current = onVisibleDateChange; + const onRowsRef = React.useRef(onVisibleRowsChange); + onRowsRef.current = onVisibleRowsChange; + const reportedRowsRef = React.useRef(null); const offsetRef = React.useRef(0); // Week at the top of the viewport; a change of row height (the "show time" // setting, a rotation) remounts the list there. Kept as the week, not its @@ -164,8 +170,15 @@ function MonthScrollViewInner({ } offsetRef.current = offset; const list = weeksRef.current; - const top = list[Math.max(0, Math.min(list.length - 1, Math.round(offset / rowHeight)))]; + const topRow = Math.max(0, Math.min(list.length - 1, Math.round(offset / rowHeight))); + const top = list[topRow]; if (top) topWeekRef.current = dayKey(top[0]); + const rowsKey = `${topRow}:${visibleRows}:${list.length}`; + if (top && rowsKey !== reportedRowsRef.current) { + reportedRowsRef.current = rowsKey; + const bottom = list[Math.min(list.length - 1, topRow + visibleRows - 1)]; + onRowsRef.current?.({ start: top[0], end: bottom[6] }); + } const row = sampledRow(offset, viewportHeight, rowHeight, list.length); const days = list[row]; if (!days) return; @@ -177,7 +190,7 @@ function MonthScrollViewInner({ setActiveMonth(key); onVisibleRef.current?.(mid); }, - [viewportHeight, rowHeight], + [viewportHeight, rowHeight, visibleRows], ); const markSettled = React.useCallback(() => { settledRef.current = true; }, []); diff --git a/src/lib/__tests__/calendar-new-event.test.ts b/src/lib/__tests__/calendar-new-event.test.ts new file mode 100644 index 000000000..2c72d5e65 --- /dev/null +++ b/src/lib/__tests__/calendar-new-event.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'vitest'; +import { dayKey } from '../calendar-utils'; +import { gregorianCalendar, jalaliCalendar } from '../calendar-system'; +import { newEventStart, shownDays, type ShownDays } from '../calendar-new-event'; + +const opts = { multiDays: 3, weekStartsOn: 1 as const, calendarSystem: gregorianCalendar }; +const keys = (shown: ShownDays | null) => shown && [dayKey(shown.range.start), dayKey(shown.range.end)]; +const first = (shown: ShownDays | null) => shown && dayKey(shown.first); +// Saturday 14 November 2026, 12:47. +const now = new Date(2026, 10, 14, 12, 47, 10); + +describe('shownDays', () => { + it('is the one day of the day view', () => { + expect(keys(shownDays('day', new Date(2026, 10, 22, 9), true, opts))).toEqual(['2026-11-22', '2026-11-22']); + }); + + it('is the multi-day view\'s days from the first one in view', () => { + expect(keys(shownDays('3day', new Date(2026, 10, 22), true, opts))).toEqual(['2026-11-22', '2026-11-24']); + expect(keys(shownDays('3day', new Date(2026, 10, 22), true, { ...opts, multiDays: 5 }))) + .toEqual(['2026-11-22', '2026-11-26']); + }); + + it('is the focused day\'s week until the week grid is scrolled', () => { + expect(keys(shownDays('week', new Date(2026, 10, 25), false, opts))).toEqual(['2026-11-23', '2026-11-29']); + expect(keys(shownDays('week', new Date(2026, 10, 25), false, { ...opts, weekStartsOn: 0 }))) + .toEqual(['2026-11-22', '2026-11-28']); + expect(keys(shownDays('week', new Date(2026, 10, 25), true, opts))).toEqual(['2026-11-25', '2026-12-01']); + }); + + it('is the month\'s grid of whole weeks, starting events on the 1st', () => { + const december = shownDays('month', new Date(2026, 11, 9), false, opts); + expect(keys(december)).toEqual(['2026-11-30', '2027-01-03']); + expect(first(december)).toBe('2026-12-01'); + // 9 December 2026 is in Azar 1405: 22 November to 21 December. + const azar = shownDays('month', new Date(2026, 11, 9), false, { ...opts, calendarSystem: jalaliCalendar }); + expect(keys(azar)).toEqual(['2026-11-16', '2026-12-27']); + expect(first(azar)).toBe('2026-11-22'); + }); + + it('is the rows the scrolling month reported, while they hold the month in the title', () => { + // The title turned to December with November's last weeks still on top. + const rows = { start: new Date(2026, 10, 16), end: new Date(2026, 11, 27) }; + const december = shownDays('month', new Date(2026, 11, 3), true, { ...opts, monthRows: rows }); + expect(keys(december)).toEqual(['2026-11-16', '2026-12-27']); + expect(first(december)).toBe('2026-12-01'); + // Scrolling back, the title turned to November with its 1st above the top row. + const back = { start: new Date(2026, 10, 9), end: new Date(2026, 11, 20) }; + const november = shownDays('month', new Date(2026, 10, 26), true, { ...opts, monthRows: back }); + expect(first(november)).toBe('2026-11-09'); + // Further on in December the title's day is from a row scrolled away. + const later = { start: new Date(2026, 11, 14), end: new Date(2027, 0, 24) }; + const lateDecember = shownDays('month', new Date(2026, 11, 3), true, { ...opts, monthRows: later }); + expect(keys(lateDecember)).toEqual(['2026-12-14', '2027-01-24']); + expect(first(lateDecember)).toBe('2026-12-14'); + // Rows from before a jump to March are not on screen any more. + const march = shownDays('month', new Date(2027, 2, 1), false, { ...opts, monthRows: rows }); + expect(keys(march)).toEqual(['2027-03-01', '2027-04-04']); + }); + + it('has no period in the schedule', () => { + expect(shownDays('agenda', new Date(2026, 10, 22), true, opts)).toBeNull(); + }); +}); + +describe('newEventStart', () => { + const at = (date: Date) => `${dayKey(date)} ${date.getHours()}:${date.getMinutes()}`; + + it('starts on the day swiped to in the day view, not on the selection left behind (#139)', () => { + const shown = shownDays('day', new Date(2026, 10, 22), true, opts); + expect(at(newEventStart(new Date(2026, 10, 14, 11, 58), shown, now))).toBe('2026-11-22 12:47'); + }); + + it('keeps the selected day while the view shows it', () => { + const week = shownDays('week', new Date(2026, 10, 10), false, opts); + expect(at(newEventStart(new Date(2026, 10, 14), week, now))).toBe('2026-11-14 12:47'); + const month = shownDays('month', new Date(2026, 10, 3), true, opts); + expect(at(newEventStart(new Date(2026, 10, 30), month, now))).toBe('2026-11-30 12:47'); + }); + + it('starts on the first day shown once the selection is off screen', () => { + const week = shownDays('week', new Date(2026, 10, 25), false, opts); + expect(at(newEventStart(new Date(2026, 10, 14), week, now))).toBe('2026-11-23 12:47'); + const month = shownDays('month', new Date(2026, 11, 9), false, opts); + expect(at(newEventStart(new Date(2026, 10, 14), month, now))).toBe('2026-12-01 12:47'); + const days = shownDays('3day', new Date(2026, 10, 12), true, opts); + expect(at(newEventStart(new Date(2026, 10, 15), days, now))).toBe('2026-11-12 12:47'); + }); + + it('keeps a selected day of the month before while its row is on screen', () => { + const rows = { start: new Date(2026, 10, 16), end: new Date(2026, 11, 27) }; + const december = shownDays('month', new Date(2026, 11, 3), true, { ...opts, monthRows: rows }); + expect(at(newEventStart(new Date(2026, 10, 20), december, now))).toBe('2026-11-20 12:47'); + expect(at(newEventStart(new Date(2026, 10, 14), december, now))).toBe('2026-12-01 12:47'); + }); + + it('keeps the selected day in the schedule', () => { + expect(at(newEventStart(new Date(2026, 10, 20), null, now))).toBe('2026-11-20 12:47'); + }); + + it('stays on the day late in the evening, when rounding up would pass midnight', () => { + const shown = shownDays('day', new Date(2026, 10, 22), true, opts); + expect(at(newEventStart(new Date(2026, 10, 14), shown, new Date(2026, 10, 14, 23, 40)))).toBe('2026-11-22 23:30'); + expect(at(newEventStart(new Date(2026, 10, 14), shown, new Date(2026, 10, 14, 23, 30, 5)))).toBe('2026-11-22 23:30'); + expect(at(newEventStart(new Date(2026, 10, 14), shown, new Date(2026, 10, 14, 23, 29, 59)))).toBe('2026-11-22 23:29'); + }); + + it('takes the time from the clock, not from when the day was selected', () => { + const shown = shownDays('day', new Date(2026, 10, 14), false, opts); + expect(at(newEventStart(new Date(2026, 10, 14, 11, 58), shown, now))).toBe('2026-11-14 12:47'); + }); +}); diff --git a/src/lib/calendar-new-event.ts b/src/lib/calendar-new-event.ts new file mode 100644 index 000000000..01c7d2651 --- /dev/null +++ b/src/lib/calendar-new-event.ts @@ -0,0 +1,80 @@ +import { addDays, startOfDay, startOfWeek } from 'date-fns'; +import { monthGridRange, type CalendarSystem } from './calendar-system'; +import type { DayRange, WeekStartsOn } from './calendar-scroll-window'; + +/** + * Where the "+" button starts a new event (#139): on a day the view shows, + * not on a selection that was scrolled off screen. + */ + +export type NewEventViewMode = 'month' | 'week' | '3day' | 'day' | 'agenda'; + +export interface ShownDays { + /** Every day on screen. */ + range: DayRange; + /** The day an event starts on when the selected one is not among them. */ + first: Date; +} + +/** The latest start that the editor's rounding keeps on the same day. */ +const LAST_START_MINUTES = 23 * 60 + 30; + +/** + * The days a view has on screen, from the day its title follows (`shown`: + * the day scrolled to, else the focused one). The scrolling month passes + * the rows it last reported (`monthRows`); they count while they hold a + * day of `shown`'s month, so rows left behind by navigation are ignored. The schedule has + * no such period and gives none. + */ +export function shownDays( + mode: NewEventViewMode, + shown: Date, + scrolled: boolean, + opts: { + multiDays: number; + weekStartsOn: WeekStartsOn; + calendarSystem: CalendarSystem; + monthRows?: DayRange | null; + }, +): ShownDays | null { + const day = startOfDay(shown); + switch (mode) { + case 'day': + return { range: { start: day, end: day }, first: day }; + case '3day': + return { range: { start: day, end: addDays(day, opts.multiDays - 1) }, first: day }; + case 'week': { + // A scrolled week grid starts on any day; otherwise on the week's first. + const start = scrolled ? day : startOfWeek(day, { weekStartsOn: opts.weekStartsOn }); + return { range: { start, end: addDays(start, 6) }, first: start }; + } + case 'month': { + const monthStart = opts.calendarSystem.startOfMonth(day); + const monthEnd = opts.calendarSystem.lastDayOfMonth(day); + const rows = opts.monthRows; + const range = + rows && startOfDay(rows.start) <= monthEnd && startOfDay(rows.end) >= monthStart + ? { start: startOfDay(rows.start), end: startOfDay(rows.end) } + : monthGridRange(day, opts.weekStartsOn, opts.calendarSystem); + // The 1st of the month in the title, or its first day still on screen. + return { range, first: monthStart >= range.start ? monthStart : range.start }; + } + case 'agenda': + return null; + } +} + +/** + * The start the editor opens on: the selected day while it is on screen, + * else the view's first day, at the time `now` reads. The editor rounds + * that up to the next half hour, so a time after 23:30 is held there and + * the event stays on its day. + */ +export function newEventStart(selected: Date, shown: ShownDays | null, now: Date): Date { + const selectedDay = startOfDay(selected); + const onScreen = !shown || (selectedDay >= shown.range.start && selectedDay <= shown.range.end); + const start = new Date(onScreen ? selectedDay : shown.first); + if (now.getHours() * 60 + now.getMinutes() >= LAST_START_MINUTES) start.setHours(23, 30, 0, 0); + else start.setHours(now.getHours(), now.getMinutes(), now.getSeconds(), now.getMilliseconds()); + return start; +} diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 711ae9dde..c62d2f954 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -75,11 +75,13 @@ import { scrollWindowLoadRange, windowStateForJump, type CalendarFocus, + type DayRange, type ScrollViewMode, type ScrollWindowOptions, type ScrollWindowState, } from '../lib/calendar-scroll-window'; import { coversRange, createRangeLoader } from '../lib/calendar-range-cache'; +import { newEventStart, shownDays } from '../lib/calendar-new-event'; import { addDaysToLocalDateTime, applySharedCalendarColors, @@ -641,6 +643,28 @@ export default function CalendarScreen() { setDetailEvent(event); }, []); + // The "+" button starts on a day the view shows (#139): swiping the grid + // moves what is on screen, not the selection. + const monthRowsRef = React.useRef(null); + const handleMonthRows = React.useCallback((rows: DayRange) => { + monthRowsRef.current = rows; + }, []); + const openCreateOnShownDay = React.useCallback(() => { + // Only the scrolling views report a day; one left over from before + // free scrolling was turned off is not on screen. + const scrolledTo = freeScroll ? visibleDate : null; + const shown = shownDays(viewMode, scrolledTo ?? focus.date, scrolledTo !== null, { + multiDays, + weekStartsOn: calendarFirstDayOfWeek, + calendarSystem: system, + monthRows: freeScroll ? monthRowsRef.current : null, + }); + openCreate(newEventStart(selectedDate, shown, displayNow())); + }, [ + viewMode, freeScroll, visibleDate, focus.date, multiDays, calendarFirstDayOfWeek, system, selectedDate, + openCreate, + ]); + const openEditDirect = React.useCallback((event: CalendarEvent) => { setModalEvent(event); setModalDate(undefined); @@ -1139,6 +1163,7 @@ export default function CalendarScreen() { onExtendStart={scrollWindow.canExtendStart ? extendWindowStart : undefined} onExtendEnd={scrollWindow.canExtendEnd ? extendWindowEnd : undefined} onVisibleDateChange={setVisibleDate} + onVisibleRowsChange={handleMonthRows} selectedDate={selectedDate} events={events} eventsByDay={eventsByDay} @@ -1237,7 +1262,7 @@ export default function CalendarScreen() { openCreate(selectedDate)} + onNewEvent={openCreateOnShownDay} onNewTask={enableCalendarTasks ? openNewTask : undefined} />