diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx index 11f21f01..ba925f56 100644 --- a/src/components/calendar/WeekView.tsx +++ b/src/components/calendar/WeekView.tsx @@ -5,6 +5,7 @@ import { StyleSheet, ScrollView, Pressable, + PanResponder, } from 'react-native'; import { addDays, format, isSameDay, isToday, startOfWeek } from 'date-fns'; import type { Calendar, CalendarEvent } from '../../api/types'; @@ -23,6 +24,7 @@ import { type CalendarWeekSegment, type EventDayIndex, } from '../../lib/calendar-utils'; +import { weekSwipeDelta } from '../../lib/calendar-navigation'; const HOUR_HEIGHT = 48; const GUTTER_WIDTH = 44; @@ -32,24 +34,31 @@ const HOURS = Array.from({ length: 24 }, (_, i) => i); interface WeekViewProps { selectedDate: Date; + /** Week to display. Defaults to selectedDate. Kept separate so paging + * the visible week (header + grid) does not depend on the highlighted day. */ + weekDate?: Date; events: CalendarEvent[]; calendars: Calendar[]; eventsByDay?: EventDayIndex; onSelectDate?: (date: Date) => void; onSelectEvent?: (event: CalendarEvent) => void; onCreateAtTime?: (date: Date) => void; + /** -1 previous week, +1 next week. Horizontal swipe on the grid. */ + onSwipeWeek?: (delta: -1 | 1) => void; weekStartsOn?: 0 | 1 | 6; timeFormat?: '12h' | '24h'; } function WeekViewInner({ selectedDate, + weekDate, events, calendars, eventsByDay, onSelectDate, onSelectEvent, onCreateAtTime, + onSwipeWeek, weekStartsOn = 0, timeFormat = '24h', }: WeekViewProps) { @@ -64,9 +73,9 @@ function WeekViewInner({ ); const weekDays = React.useMemo(() => { - const start = startOfWeek(selectedDate, { weekStartsOn }); + const start = startOfWeek(weekDate ?? selectedDate, { weekStartsOn }); return Array.from({ length: 7 }, (_, i) => addDays(start, i)); - }, [selectedDate, weekStartsOn]); + }, [weekDate, selectedDate, weekStartsOn]); // Multi-day all-day events render as one bar across the days they span. // Timed events that consume the entire day get promoted to this strip too. @@ -122,6 +131,20 @@ function WeekViewInner({ }); }, []); + const onSwipeWeekRef = React.useRef(onSwipeWeek); + onSwipeWeekRef.current = onSwipeWeek; + const weekPan = React.useRef( + PanResponder.create({ + onMoveShouldSetPanResponder: (_, g) => + Math.abs(g.dx) > 12 && Math.abs(g.dx) > Math.abs(g.dy) * 1.5, + onPanResponderRelease: (_, g) => { + const delta = weekSwipeDelta(g.dx, g.dy, g.vx); + if (delta !== 0) onSwipeWeekRef.current?.(delta); + }, + onPanResponderTerminationRequest: () => false, + }), + ).current; + const handleSlotLongPress = (day: Date, hour: number) => { if (!onCreateAtTime) return; const date = new Date(day); @@ -161,7 +184,7 @@ function WeekViewInner({ }; return ( - + {weekDays.map(dayHeader)} diff --git a/src/lib/__tests__/calendar-navigation.test.ts b/src/lib/__tests__/calendar-navigation.test.ts new file mode 100644 index 00000000..00c59bcc --- /dev/null +++ b/src/lib/__tests__/calendar-navigation.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { format, startOfWeek } from 'date-fns'; +import { applyCalendarNavigation, shiftViewDate, weekSwipeDelta } from '../calendar-navigation'; + +function ymd(d: Date): string { + return format(d, 'yyyy-MM-dd'); +} + +describe('shiftViewDate', () => { + it('moves a week at a time in week view', () => { + const wed = new Date(2026, 8, 16); // Wed 16 Sep 2026 + expect(ymd(shiftViewDate('week', wed, 1))).toBe('2026-09-23'); + expect(ymd(shiftViewDate('week', wed, -1))).toBe('2026-09-09'); + }); + + it('moves a month at a time in month view', () => { + const d = new Date(2026, 8, 16); + expect(ymd(shiftViewDate('month', d, 1))).toBe('2026-10-16'); + expect(ymd(shiftViewDate('month', d, -1))).toBe('2026-08-16'); + }); +}); + +describe('applyCalendarNavigation', () => { + it('week prev/next moves the selected day with the visible week', () => { + const current = new Date(2026, 8, 16); + const selected = new Date(2026, 8, 16); + const next = applyCalendarNavigation('week', current, selected, 1); + + expect(ymd(next.currentDate)).toBe('2026-09-23'); + expect(ymd(next.selectedDate)).toBe('2026-09-23'); + expect(ymd(startOfWeek(next.selectedDate, { weekStartsOn: 1 }))).toBe('2026-09-21'); + expect(ymd(startOfWeek(next.currentDate, { weekStartsOn: 1 }))).toBe( + ymd(startOfWeek(next.selectedDate, { weekStartsOn: 1 })), + ); + }); + + it('week nav still moves the grid when selectedDate has drifted from currentDate', () => { + // Header was already advanced (currentDate) while the grid stayed on + // selectedDate — the original week-view paging bug. + const current = new Date(2026, 8, 23); + const selected = new Date(2026, 8, 16); + const next = applyCalendarNavigation('week', current, selected, 1); + + expect(ymd(next.currentDate)).toBe('2026-09-30'); + expect(ymd(next.selectedDate)).toBe('2026-09-30'); + }); + + it('month prev/next keeps the highlighted day', () => { + const current = new Date(2026, 8, 1); + const selected = new Date(2026, 8, 16); + const next = applyCalendarNavigation('month', current, selected, 1); + + expect(ymd(next.currentDate)).toBe('2026-10-01'); + expect(ymd(next.selectedDate)).toBe('2026-09-16'); + }); +}); + +describe('weekSwipeDelta', () => { + it('pages previous on a rightward swipe', () => { + expect(weekSwipeDelta(80, 10)).toBe(-1); + }); + + it('pages next on a leftward swipe', () => { + expect(weekSwipeDelta(-80, 10)).toBe(1); + }); + + it('leaves vertical hour-grid scrolls alone', () => { + expect(weekSwipeDelta(20, 80)).toBe(0); + }); + + it('pages on a short fast fling', () => { + expect(weekSwipeDelta(-45, 5, -0.8)).toBe(1); + }); +}); diff --git a/src/lib/calendar-navigation.ts b/src/lib/calendar-navigation.ts new file mode 100644 index 00000000..bd7267c3 --- /dev/null +++ b/src/lib/calendar-navigation.ts @@ -0,0 +1,50 @@ +import { addDays, addMonths, addWeeks, subMonths, subWeeks } from 'date-fns'; + +export type CalendarViewMode = 'month' | 'week' | 'agenda'; + +export const AGENDA_DAYS = 30; + +export function shiftViewDate( + viewMode: CalendarViewMode, + date: Date, + delta: -1 | 1, +): Date { + if (viewMode === 'month') return delta < 0 ? subMonths(date, 1) : addMonths(date, 1); + if (viewMode === 'week') return delta < 0 ? subWeeks(date, 1) : addWeeks(date, 1); + return addDays(date, delta * AGENDA_DAYS); +} + +/** + * Prev/next in week and agenda view moves the visible period and the + * selected day together. Month view only moves the displayed month: the + * highlighted day stays, matching a tap in the month grid. + */ +export function applyCalendarNavigation( + viewMode: CalendarViewMode, + currentDate: Date, + selectedDate: Date, + delta: -1 | 1, +): { currentDate: Date; selectedDate: Date } { + const nextCurrent = shiftViewDate(viewMode, currentDate, delta); + if (viewMode === 'week' || viewMode === 'agenda') { + return { currentDate: nextCurrent, selectedDate: nextCurrent }; + } + return { currentDate: nextCurrent, selectedDate }; +} + +const SWIPE_DX = 60; +const SWIPE_FLING_DX = 40; +const SWIPE_FLING_VX = 0.5; +const SWIPE_DIRECTION_BIAS = 1.5; + +/** + * Horizontal week paging: swipe right → previous week, swipe left → next. + * Returns 0 when the gesture is vertical or too short, so the hour grid + * keeps scrolling. + */ +export function weekSwipeDelta(dx: number, dy: number, vx = 0): -1 | 1 | 0 { + if (Math.abs(dx) < Math.abs(dy) * SWIPE_DIRECTION_BIAS) return 0; + if (dx > SWIPE_DX || (dx > SWIPE_FLING_DX && vx > SWIPE_FLING_VX)) return -1; + if (dx < -SWIPE_DX || (dx < -SWIPE_FLING_DX && vx < -SWIPE_FLING_VX)) return 1; + return 0; +} diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index aa94446d..8f86c81d 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -29,10 +29,6 @@ import { endOfWeek, addDays, isToday, - addMonths, - subMonths, - addWeeks, - subWeeks, type Locale, } from 'date-fns'; import { useCalendarLocale } from '../lib/calendar-locale'; @@ -76,6 +72,7 @@ import { isRecurringSeriesMember, truncateRecurrenceRules, } from '../lib/recurrence-overrides'; +import { AGENDA_DAYS, applyCalendarNavigation, type CalendarViewMode } from '../lib/calendar-navigation'; import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar'; import { useContactsStore } from '../stores/contacts-store'; import { useLocaleStore } from '../stores/locale-store'; @@ -86,7 +83,7 @@ import { shareEventICS } from '../lib/calendar-ics-export'; import * as Clipboard from 'expo-clipboard'; import type { Calendar, CalendarEvent, RecurrenceRule } from '../api/types'; -type ViewMode = 'month' | 'week' | 'agenda'; +type ViewMode = CalendarViewMode; type PendingAction = | { kind: 'edit'; @@ -98,7 +95,6 @@ type PendingAction = | { kind: 'delete'; event: CalendarEvent } | null; -const AGENDA_DAYS = 30; const RANGE_BUFFER_DAYS = 14; type WeekStart = 0 | 1 | 6; @@ -358,20 +354,16 @@ export default function CalendarScreen() { }, [viewMode, currentDate, ensureRange, calendarFirstDayOfWeek]); const goPrev = React.useCallback(() => { - setCurrentDate((d) => { - if (viewMode === 'month') return subMonths(d, 1); - if (viewMode === 'week') return subWeeks(d, 1); - return addDays(d, -AGENDA_DAYS); - }); - }, [viewMode]); + const next = applyCalendarNavigation(viewMode, currentDate, selectedDate, -1); + setCurrentDate(next.currentDate); + setSelectedDate(next.selectedDate); + }, [viewMode, currentDate, selectedDate]); const goNext = React.useCallback(() => { - setCurrentDate((d) => { - if (viewMode === 'month') return addMonths(d, 1); - if (viewMode === 'week') return addWeeks(d, 1); - return addDays(d, AGENDA_DAYS); - }); - }, [viewMode]); + const next = applyCalendarNavigation(viewMode, currentDate, selectedDate, 1); + setCurrentDate(next.currentDate); + setSelectedDate(next.selectedDate); + }, [viewMode, currentDate, selectedDate]); const goToday = React.useCallback(() => { const today = new Date(); @@ -768,7 +760,10 @@ export default function CalendarScreen() { setViewMode(mode)} + onPress={() => { + if (mode === 'week') setCurrentDate(selectedDate); + setViewMode(mode); + }} > @@ -818,6 +813,7 @@ export default function CalendarScreen() { {viewMode === 'week' && ( (delta < 0 ? goPrev() : goNext())} /> )} {viewMode === 'agenda' && (