From ccda0d41df4ca321850949f107eba24450b54c1c Mon Sep 17 00:00:00 2001
From: lucletoffe <15689941+lucletoffe@users.noreply.github.com>
Date: Mon, 21 Sep 2026 10:27:10 +0200
Subject: [PATCH 1/2] fix(calendar): page the week grid with prev/next, not
only the header
Week view kept two dates: currentDate drove the header range and event
fetch, selectedDate drove the grid. Prev/next only moved currentDate, so
the title jumped while the displayed week stayed put.
---
src/components/calendar/WeekView.tsx | 8 ++-
src/lib/__tests__/calendar-navigation.test.ts | 56 +++++++++++++++++++
src/lib/calendar-navigation.ts | 33 +++++++++++
src/screens/CalendarScreen.tsx | 34 +++++------
4 files changed, 110 insertions(+), 21 deletions(-)
create mode 100644 src/lib/__tests__/calendar-navigation.test.ts
create mode 100644 src/lib/calendar-navigation.ts
diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx
index 11f21f01..a1c1216e 100644
--- a/src/components/calendar/WeekView.tsx
+++ b/src/components/calendar/WeekView.tsx
@@ -32,6 +32,9 @@ 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;
@@ -44,6 +47,7 @@ interface WeekViewProps {
function WeekViewInner({
selectedDate,
+ weekDate,
events,
calendars,
eventsByDay,
@@ -64,9 +68,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.
diff --git a/src/lib/__tests__/calendar-navigation.test.ts b/src/lib/__tests__/calendar-navigation.test.ts
new file mode 100644
index 00000000..46680618
--- /dev/null
+++ b/src/lib/__tests__/calendar-navigation.test.ts
@@ -0,0 +1,56 @@
+import { describe, it, expect } from 'vitest';
+import { format, startOfWeek } from 'date-fns';
+import { applyCalendarNavigation, shiftViewDate } 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');
+ });
+});
diff --git a/src/lib/calendar-navigation.ts b/src/lib/calendar-navigation.ts
new file mode 100644
index 00000000..2883e440
--- /dev/null
+++ b/src/lib/calendar-navigation.ts
@@ -0,0 +1,33 @@
+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 };
+}
diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx
index aa94446d..3eae0d29 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' && (
Date: Mon, 21 Sep 2026 12:11:25 +0200
Subject: [PATCH 2/2] fix(calendar): swipe between weeks in week view
Prev/next already moved the grid; a horizontal swipe did nothing, so
paging weeks still felt broken. A right swipe goes to the previous
week, a left swipe to the next, without stealing the hour-grid scroll.
---
src/components/calendar/WeekView.tsx | 21 ++++++++++++++++++-
src/lib/__tests__/calendar-navigation.test.ts | 20 +++++++++++++++++-
src/lib/calendar-navigation.ts | 17 +++++++++++++++
src/screens/CalendarScreen.tsx | 1 +
4 files changed, 57 insertions(+), 2 deletions(-)
diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx
index a1c1216e..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;
@@ -41,6 +43,8 @@ interface WeekViewProps {
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';
}
@@ -54,6 +58,7 @@ function WeekViewInner({
onSelectDate,
onSelectEvent,
onCreateAtTime,
+ onSwipeWeek,
weekStartsOn = 0,
timeFormat = '24h',
}: WeekViewProps) {
@@ -126,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);
@@ -165,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
index 46680618..00c59bcc 100644
--- a/src/lib/__tests__/calendar-navigation.test.ts
+++ b/src/lib/__tests__/calendar-navigation.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { format, startOfWeek } from 'date-fns';
-import { applyCalendarNavigation, shiftViewDate } from '../calendar-navigation';
+import { applyCalendarNavigation, shiftViewDate, weekSwipeDelta } from '../calendar-navigation';
function ymd(d: Date): string {
return format(d, 'yyyy-MM-dd');
@@ -54,3 +54,21 @@ describe('applyCalendarNavigation', () => {
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
index 2883e440..bd7267c3 100644
--- a/src/lib/calendar-navigation.ts
+++ b/src/lib/calendar-navigation.ts
@@ -31,3 +31,20 @@ export function applyCalendarNavigation(
}
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 3eae0d29..8f86c81d 100644
--- a/src/screens/CalendarScreen.tsx
+++ b/src/screens/CalendarScreen.tsx
@@ -822,6 +822,7 @@ export default function CalendarScreen() {
onSelectDate={handleSelectDate}
onSelectEvent={handleSelectEvent}
onCreateAtTime={openCreate}
+ onSwipeWeek={(delta) => (delta < 0 ? goPrev() : goNext())}
/>
)}
{viewMode === 'agenda' && (