Skip to content
Open
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
29 changes: 26 additions & 3 deletions src/components/calendar/WeekView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -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) {
Expand All @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -161,7 +184,7 @@ function WeekViewInner({
};

return (
<View style={styles.container}>
<View style={styles.container} {...weekPan.panHandlers}>
<View style={styles.headerRow}>
<View style={styles.gutter} />
<View style={styles.dayHeaders}>{weekDays.map(dayHeader)}</View>
Expand Down
74 changes: 74 additions & 0 deletions src/lib/__tests__/calendar-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
50 changes: 50 additions & 0 deletions src/lib/calendar-navigation.ts
Original file line number Diff line number Diff line change
@@ -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;
}
35 changes: 16 additions & 19 deletions src/screens/CalendarScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,6 @@ import {
endOfWeek,
addDays,
isToday,
addMonths,
subMonths,
addWeeks,
subWeeks,
type Locale,
} from 'date-fns';
import { useCalendarLocale } from '../lib/calendar-locale';
Expand Down Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -768,7 +760,10 @@ export default function CalendarScreen() {
<Pressable
key={mode}
style={[styles.viewToggleBtn, active && styles.viewToggleBtnActive]}
onPress={() => setViewMode(mode)}
onPress={() => {
if (mode === 'week') setCurrentDate(selectedDate);
setViewMode(mode);
}}
>
<Icon size={16} color={active ? c.primary : c.textMuted} />
</Pressable>
Expand Down Expand Up @@ -818,6 +813,7 @@ export default function CalendarScreen() {
{viewMode === 'week' && (
<WeekView
selectedDate={selectedDate}
weekDate={currentDate}
events={events}
eventsByDay={eventsByDay}
calendars={calendars}
Expand All @@ -826,6 +822,7 @@ export default function CalendarScreen() {
onSelectDate={handleSelectDate}
onSelectEvent={handleSelectEvent}
onCreateAtTime={openCreate}
onSwipeWeek={(delta) => (delta < 0 ? goPrev() : goNext())}
/>
)}
{viewMode === 'agenda' && (
Expand Down
Loading