Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions src/components/calendar/MonthScrollView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -90,6 +92,7 @@ function MonthScrollViewInner({
onExtendStart,
onExtendEnd,
onVisibleDateChange,
onVisibleRowsChange,
onSelectDate,
onLongPressDate,
}: MonthScrollViewProps) {
Expand Down Expand Up @@ -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<string | null>(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
Expand All @@ -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;
Expand All @@ -177,7 +190,7 @@ function MonthScrollViewInner({
setActiveMonth(key);
onVisibleRef.current?.(mid);
},
[viewportHeight, rowHeight],
[viewportHeight, rowHeight, visibleRows],
);

const markSettled = React.useCallback(() => { settledRef.current = true; }, []);
Expand Down
111 changes: 111 additions & 0 deletions src/lib/__tests__/calendar-new-event.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
80 changes: 80 additions & 0 deletions src/lib/calendar-new-event.ts
Original file line number Diff line number Diff line change
@@ -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;
}
27 changes: 26 additions & 1 deletion src/screens/CalendarScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<DayRange | null>(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);
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -1237,7 +1262,7 @@ export default function CalendarScreen() {
</View>

<CalendarFab
onNewEvent={() => openCreate(selectedDate)}
onNewEvent={openCreateOnShownDay}
onNewTask={enableCalendarTasks ? openNewTask : undefined}
/>

Expand Down
Loading