feat(calendar): add swipe navigation for calendar views

Implement horizontal swipe/trackpad navigation between calendar periods:
- Add ViewCarousel component with animated page transitions
- Support touch swipe, trackpad scroll, and wheel navigation
- Create useSwipeNavigation composable for reusable swipe handling
- Add dateNavigation utility for calculating view-specific date offsets

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-14 22:22:15 +01:00
parent 9e7113982e
commit e0d7b3d13d
11 changed files with 618 additions and 65 deletions

View file

@ -10,10 +10,15 @@
import type { CalendarEvent } from '@calendar/shared';
interface Props {
/** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */
date?: Date;
onEventClick?: (event: CalendarEvent) => void;
}
let { onEventClick }: Props = $props();
let { date, onEventClick }: Props = $props();
// Use provided date or fall back to viewStore
let effectiveDate = $derived(date ?? viewStore.currentDate);
// Group events by date
let groupedEvents = $derived.by(() => {
@ -24,7 +29,7 @@
const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id));
// Filter events that start from current date onwards
const startDate = startOfDay(viewStore.currentDate);
const startDate = startOfDay(effectiveDate);
const groups: Map<string, CalendarEvent[]> = new Map();

View file

@ -27,12 +27,17 @@
import type { CalendarEvent } from '@calendar/shared';
interface Props {
/** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */
date?: Date;
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
onEventClick?: (event: CalendarEvent) => void;
onTaskClick?: (task: Task) => void;
}
let { onQuickCreate, onEventClick, onTaskClick }: Props = $props();
let { date, onQuickCreate, onEventClick, onTaskClick }: Props = $props();
// Use provided date or fall back to viewStore
let effectiveDate = $derived(date ?? viewStore.currentDate);
// Use shared constants
const HOUR_HEIGHT = HOUR_HEIGHT_PX;
@ -55,7 +60,7 @@
// Get timed events, filtering out those outside visible range when hour filter is enabled
let timedEvents = $derived(
getVisibleTimedEvents(
eventsStore.getEventsForDay(viewStore.currentDate),
eventsStore.getEventsForDay(effectiveDate),
calendarsStore.visibleCalendars,
{
filterHoursEnabled: settingsStore.filterHoursEnabled,
@ -71,7 +76,7 @@
return { before: [], after: [] };
}
return getVisibleOverflowEvents(
eventsStore.getEventsForDay(viewStore.currentDate),
eventsStore.getEventsForDay(effectiveDate),
calendarsStore.visibleCalendars,
settingsStore.dayStartHour,
settingsStore.dayEndHour
@ -80,7 +85,7 @@
let allDayEvents = $derived(
getVisibleAllDayEvents(
eventsStore.getEventsForDay(viewStore.currentDate),
eventsStore.getEventsForDay(effectiveDate),
calendarsStore.visibleCalendars
)
);
@ -106,7 +111,7 @@
// Get birthdays for current day (if enabled in settings)
let birthdays = $derived.by(() => {
if (!settingsStore.showBirthdays) return [];
return birthdaysStore.getBirthdaysForDay(viewStore.currentDate);
return birthdaysStore.getBirthdaysForDay(effectiveDate);
});
// ============================================================================
@ -225,7 +230,7 @@
const duration = differenceInMinutes(end, start);
// Create new start time on same day
let newStart = new Date(viewStore.currentDate);
let newStart = new Date(effectiveDate);
newStart = setHours(newStart, Math.floor(clampedMinutes / 60));
newStart = setMinutes(newStart, clampedMinutes % 60);
newStart.setSeconds(0, 0);
@ -327,7 +332,7 @@
firstVisibleHour * 60,
Math.min(adjustedMinutes, origEndMinutes - SNAP_MINUTES)
);
newStart = setHours(new Date(viewStore.currentDate), Math.floor(newStartMinutes / 60));
newStart = setHours(new Date(effectiveDate), Math.floor(newStartMinutes / 60));
newStart = setMinutes(newStart, newStartMinutes % 60);
newStart.setSeconds(0, 0);
} else {
@ -335,7 +340,7 @@
lastVisibleHour * 60,
Math.max(adjustedMinutes, origStartMinutes + SNAP_MINUTES)
);
newEnd = setHours(new Date(viewStore.currentDate), Math.floor(newEndMinutes / 60));
newEnd = setHours(new Date(effectiveDate), Math.floor(newEndMinutes / 60));
newEnd = setMinutes(newEnd, newEndMinutes % 60);
newEnd.setSeconds(0, 0);
}
@ -586,7 +591,7 @@
const endTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
await todosStore.updateTodo(data.taskId, {
scheduledDate: format(viewStore.currentDate, 'yyyy-MM-dd'),
scheduledDate: format(effectiveDate, 'yyyy-MM-dd'),
scheduledStartTime: startTime,
scheduledEndTime: endTime,
estimatedDuration: duration,
@ -659,7 +664,7 @@
* Get scheduled tasks for current day
*/
function getScheduledTasks(): Task[] {
return todosStore.getScheduledTasksForDay(viewStore.currentDate);
return todosStore.getScheduledTasksForDay(effectiveDate);
}
function handleEventClick(event: CalendarEvent, e: MouseEvent) {
@ -683,7 +688,7 @@
// Don't create event if dragging or resizing
if (isDragging || isResizing) return;
const startTime = new Date(viewStore.currentDate);
const startTime = new Date(effectiveDate);
startTime.setHours(hour, 0, 0, 0);
if (onQuickCreate) {
@ -756,7 +761,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="day-column"
class:today={isToday(viewStore.currentDate)}
class:today={isToday(effectiveDate)}
class:drop-target={isSidebarDropTarget}
bind:this={dayColumnRef}
ondragover={handleSidebarDragOver}
@ -855,7 +860,7 @@
{/if}
<!-- Current time indicator -->
{#if isToday(viewStore.currentDate)}
{#if isToday(effectiveDate)}
<div class="time-indicator" style="top: {currentTimePosition}%"></div>
{/if}
</div>

View file

@ -36,16 +36,21 @@
import type { CalendarEvent } from '@calendar/shared';
interface Props {
/** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */
date?: Date;
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
onEventClick?: (event: CalendarEvent) => void;
}
let { onQuickCreate, onEventClick }: Props = $props();
let { date, onQuickCreate, onEventClick }: Props = $props();
// Use provided date or fall back to viewStore
let effectiveDate = $derived(date ?? viewStore.currentDate);
// Get all days to display in the month grid (including days from prev/next months)
let allCalendarDays = $derived.by(() => {
const monthStart = startOfMonth(viewStore.currentDate);
const monthEnd = endOfMonth(viewStore.currentDate);
const monthStart = startOfMonth(effectiveDate);
const monthEnd = endOfMonth(effectiveDate);
const calendarStart = startOfWeek(monthStart, { weekStartsOn: settingsStore.weekStartsOn });
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: settingsStore.weekStartsOn });
@ -85,7 +90,6 @@
let isDragging = $state(false);
let draggedEvent = $state<CalendarEvent | null>(null);
let dragTargetDay = $state<Date | null>(null);
let monthViewRef = $state<HTMLElement | null>(null);
// Store for day cell refs
let dayCellRefs = $state<Map<string, HTMLElement>>(new Map());
@ -276,7 +280,7 @@
}
</script>
<div class="month-view" style="--column-count: {columnCount}" bind:this={monthViewRef}>
<div class="month-view" style="--column-count: {columnCount}">
<!-- Week day headers -->
<div class="weekday-headers">
{#each weekDays as day}
@ -292,7 +296,7 @@
{@const isDropTarget = isDragging && dragTargetDay && isSameDay(day, dragTargetDay)}
<div
class="day-cell"
class:other-month={!isSameMonth(day, viewStore.currentDate)}
class:other-month={!isSameMonth(day, effectiveDate)}
class:today={isToday(day)}
class:drop-target={isDropTarget}
use:bindDayCellRef={day}

View file

@ -43,11 +43,31 @@
// Props
interface Props {
dayCount: 5 | 10 | 14;
/** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */
date?: Date;
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
onEventClick?: (event: CalendarEvent) => void;
onTaskClick?: (task: Task) => void;
}
let { dayCount, onQuickCreate, onEventClick, onTaskClick }: Props = $props();
let { dayCount, date, onQuickCreate, onEventClick, onTaskClick }: Props = $props();
// Use provided date or fall back to viewStore
let effectiveDate = $derived(date ?? viewStore.currentDate);
// Calculate view range based on effective date
let effectiveViewRange = $derived.by(() => {
if (date) {
// Calculate range for the provided date based on day count
const end = new Date(date);
end.setDate(end.getDate() + dayCount - 1);
return {
start: date,
end: end,
};
}
// Use viewStore range when no date override
return viewStore.viewRange;
});
// Get date-fns locale based on current app locale
const dateLocales = { de, en: enUS, fr, es, it };
@ -58,8 +78,8 @@
// Generate days based on view range, optionally filtering weekends
let allDays = $derived(
eachDayOfInterval({
start: viewStore.viewRange.start,
end: viewStore.viewRange.end,
start: effectiveViewRange.start,
end: effectiveViewRange.end,
})
);

View file

@ -0,0 +1,278 @@
<script lang="ts">
import { browser } from '$app/environment';
import { viewStore } from '$lib/stores/view.svelte';
import { getOffsetDate } from '$lib/utils/dateNavigation';
import WeekView from './WeekView.svelte';
import DayView from './DayView.svelte';
import MonthView from './MonthView.svelte';
import MultiDayView from './MultiDayView.svelte';
import YearView from './YearView.svelte';
import AgendaView from './AgendaView.svelte';
import type { CalendarEvent } from '@calendar/shared';
interface Props {
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
onEventClick?: (event: CalendarEvent) => void;
disableSwipe?: boolean;
}
let { onQuickCreate, onEventClick, disableSwipe = false }: Props = $props();
// Swipe tracking state
let offsetX = $state(0);
let startX = $state(0);
let isSwiping = $state(false);
let isAnimating = $state(false);
// Container refs
let viewportEl: HTMLDivElement;
let viewportWidth = $state(0);
// Threshold: 15% of viewport width triggers navigation
const SNAP_THRESHOLD = 0.15;
// Debounce time for wheel events
const WHEEL_DEBOUNCE_MS = 150;
let wheelDebounceTimer: ReturnType<typeof setTimeout> | null = null;
// Calculate dates for previous/current/next views
let prevDate = $derived(getOffsetDate(viewStore.currentDate, viewStore.viewType, -1));
let currentDate = $derived(viewStore.currentDate);
let nextDate = $derived(getOffsetDate(viewStore.currentDate, viewStore.viewType, 1));
// Update viewport width on mount and resize
$effect(() => {
if (!browser || !viewportEl) return;
const updateWidth = () => {
viewportWidth = viewportEl.offsetWidth;
};
updateWidth();
const resizeObserver = new ResizeObserver(updateWidth);
resizeObserver.observe(viewportEl);
return () => resizeObserver.disconnect();
});
// Wheel handler (trackpad horizontal scroll)
function handleWheel(e: WheelEvent) {
if (disableSwipe || isAnimating) return;
// Only handle horizontal scrolling (deltaX dominant)
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
// Don't interfere with event dragging
const target = e.target as HTMLElement;
if (target.closest('[data-event-id]') || target.closest('[data-dragging]')) return;
e.preventDefault();
// Update offset (invert for natural scrolling direction)
offsetX += e.deltaX * -1;
// Clamp to max 1 page in each direction
offsetX = Math.max(-viewportWidth, Math.min(viewportWidth, offsetX));
// Debounced snap check
if (wheelDebounceTimer) clearTimeout(wheelDebounceTimer);
wheelDebounceTimer = setTimeout(() => {
snapToPage();
}, WHEEL_DEBOUNCE_MS);
}
// Touch handlers
function handleTouchStart(e: TouchEvent) {
if (disableSwipe || isAnimating) return;
// Don't interfere with event dragging
const target = e.target as HTMLElement;
if (target.closest('[data-event-id]') || target.closest('[data-dragging]')) return;
startX = e.touches[0].clientX;
isSwiping = true;
// Cancel any pending wheel snap
if (wheelDebounceTimer) {
clearTimeout(wheelDebounceTimer);
wheelDebounceTimer = null;
}
}
function handleTouchMove(e: TouchEvent) {
if (!isSwiping || disableSwipe) return;
const currentX = e.touches[0].clientX;
offsetX = currentX - startX;
// Clamp to max 1 page in each direction
offsetX = Math.max(-viewportWidth, Math.min(viewportWidth, offsetX));
}
function handleTouchEnd() {
if (!isSwiping) return;
isSwiping = false;
snapToPage();
}
function handleTouchCancel() {
if (!isSwiping) return;
isSwiping = false;
// Snap back to current on cancel
animateToOffset(0, () => {});
}
// Snap to page based on current offset
function snapToPage() {
if (isAnimating || viewportWidth === 0) return;
isAnimating = true;
const threshold = viewportWidth * SNAP_THRESHOLD;
if (offsetX > threshold) {
// Snap to previous
animateToOffset(viewportWidth, () => {
viewStore.goToPrevious();
offsetX = 0;
isAnimating = false;
});
} else if (offsetX < -threshold) {
// Snap to next
animateToOffset(-viewportWidth, () => {
viewStore.goToNext();
offsetX = 0;
isAnimating = false;
});
} else {
// Snap back to current
animateToOffset(0, () => {
isAnimating = false;
});
}
}
function animateToOffset(targetX: number, onComplete: () => void) {
offsetX = targetX;
// Wait for CSS transition to complete
setTimeout(onComplete, 300);
}
// Computed transform style
let transformStyle = $derived(`transform: translateX(calc(-33.333% + ${offsetX}px))`);
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="carousel-viewport"
bind:this={viewportEl}
onwheel={handleWheel}
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
ontouchcancel={handleTouchCancel}
>
<div class="carousel-track" class:animating={isAnimating} style={transformStyle}>
<!-- Previous View -->
<div class="carousel-page" class:inactive={!isSwiping && offsetX <= 0}>
{#if viewStore.viewType === 'day'}
<DayView date={prevDate} />
{:else if viewStore.viewType === '5day'}
<MultiDayView dayCount={5} date={prevDate} />
{:else if viewStore.viewType === 'week'}
<WeekView date={prevDate} />
{:else if viewStore.viewType === '10day'}
<MultiDayView dayCount={10} date={prevDate} />
{:else if viewStore.viewType === '14day'}
<MultiDayView dayCount={14} date={prevDate} />
{:else if viewStore.viewType === 'month'}
<MonthView date={prevDate} />
{:else if viewStore.viewType === 'year'}
<YearView date={prevDate} />
{:else if viewStore.viewType === 'agenda'}
<AgendaView date={prevDate} />
{/if}
</div>
<!-- Current View (main interactive view) -->
<div class="carousel-page current">
{#if viewStore.viewType === 'day'}
<DayView {onQuickCreate} {onEventClick} />
{:else if viewStore.viewType === '5day'}
<MultiDayView dayCount={5} {onQuickCreate} {onEventClick} />
{:else if viewStore.viewType === 'week'}
<WeekView {onQuickCreate} {onEventClick} />
{:else if viewStore.viewType === '10day'}
<MultiDayView dayCount={10} {onQuickCreate} {onEventClick} />
{:else if viewStore.viewType === '14day'}
<MultiDayView dayCount={14} {onQuickCreate} {onEventClick} />
{:else if viewStore.viewType === 'month'}
<MonthView {onQuickCreate} {onEventClick} />
{:else if viewStore.viewType === 'year'}
<YearView {onQuickCreate} {onEventClick} />
{:else if viewStore.viewType === 'agenda'}
<AgendaView {onEventClick} />
{:else}
<WeekView {onQuickCreate} {onEventClick} />
{/if}
</div>
<!-- Next View -->
<div class="carousel-page" class:inactive={!isSwiping && offsetX >= 0}>
{#if viewStore.viewType === 'day'}
<DayView date={nextDate} />
{:else if viewStore.viewType === '5day'}
<MultiDayView dayCount={5} date={nextDate} />
{:else if viewStore.viewType === 'week'}
<WeekView date={nextDate} />
{:else if viewStore.viewType === '10day'}
<MultiDayView dayCount={10} date={nextDate} />
{:else if viewStore.viewType === '14day'}
<MultiDayView dayCount={14} date={nextDate} />
{:else if viewStore.viewType === 'month'}
<MonthView date={nextDate} />
{:else if viewStore.viewType === 'year'}
<YearView date={nextDate} />
{:else if viewStore.viewType === 'agenda'}
<AgendaView date={nextDate} />
{/if}
</div>
</div>
</div>
<style>
.carousel-viewport {
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
touch-action: pan-y;
}
.carousel-track {
display: flex;
width: 300%;
height: 100%;
will-change: transform;
}
.carousel-track.animating {
transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.carousel-page {
width: 33.333%;
height: 100%;
flex-shrink: 0;
overflow: hidden;
}
/* Inactive pages have reduced interactivity for performance */
.carousel-page.inactive {
pointer-events: none;
}
.carousel-page.current {
/* Always interactive */
pointer-events: auto;
}
</style>

View file

@ -32,6 +32,8 @@
setHours,
setMinutes,
getWeek,
startOfWeek,
endOfWeek,
} from 'date-fns';
import { de, enUS, fr, es, it } from 'date-fns/locale';
import { locale, _ } from 'svelte-i18n';
@ -39,12 +41,31 @@
import type { CalendarEvent } from '@calendar/shared';
interface Props {
/** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */
date?: Date;
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
onEventClick?: (event: CalendarEvent) => void;
onTaskClick?: (task: Task) => void;
}
let { onQuickCreate, onEventClick, onTaskClick }: Props = $props();
let { date, onQuickCreate, onEventClick, onTaskClick }: Props = $props();
// Use provided date or fall back to viewStore
let effectiveDate = $derived(date ?? viewStore.currentDate);
// Calculate view range based on effective date
let effectiveViewRange = $derived.by(() => {
if (date) {
// Calculate range for the provided date
const weekStartsOn = settingsStore.weekStartsOn;
return {
start: startOfWeek(date, { weekStartsOn }),
end: endOfWeek(date, { weekStartsOn }),
};
}
// Use viewStore range when no date override
return viewStore.viewRange;
});
// Use shared constants
const HOUR_HEIGHT = HOUR_HEIGHT_PX;
@ -59,8 +80,8 @@
// Generate days of the week, optionally filtering weekends
let allDays = $derived(
eachDayOfInterval({
start: viewStore.viewRange.start,
end: viewStore.viewRange.end,
start: effectiveViewRange.start,
end: effectiveViewRange.end,
})
);
@ -70,7 +91,7 @@
// Get week number for display
let weekNumber = $derived(
getWeek(viewStore.viewRange.start, { weekStartsOn: settingsStore.weekStartsOn })
getWeek(effectiveViewRange.start, { weekStartsOn: settingsStore.weekStartsOn })
);
// Use composables for hour filtering and time indicator

View file

@ -19,14 +19,19 @@
import type { CalendarViewType, CalendarEvent } from '@calendar/shared';
interface Props {
/** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */
date?: Date;
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
onEventClick?: (event: CalendarEvent) => void;
}
let { onQuickCreate, onEventClick }: Props = $props();
let { date, onQuickCreate, onEventClick }: Props = $props();
// Use provided date or fall back to viewStore
let effectiveDate = $derived(date ?? viewStore.currentDate);
// Derived values
let year = $derived(viewStore.currentDate.getFullYear());
let year = $derived(effectiveDate.getFullYear());
let months = $derived(Array.from({ length: 12 }, (_, i) => new Date(year, i, 1)));

View file

@ -26,6 +26,9 @@ export { useCalendarKeyboard, type CancellableOperation } from './useCalendarKey
// Birthday popover management
export { useBirthdayPopover } from './useBirthdayPopover.svelte';
// Swipe/scroll navigation for view switching
export { useSwipeNavigation, type SwipeNavigationOptions } from './useSwipeNavigation.svelte';
// Legacy exports (kept for backwards compatibility, may be removed later)
export { useDragDrop, type DragDropConfig, type DragState } from './useDragDrop.svelte';
export { useResize, type ResizeConfig, type ResizeState } from './useResize.svelte';

View file

@ -0,0 +1,182 @@
/**
* Swipe Navigation Composable
* Enables horizontal swipe/scroll navigation for calendar views
*
* Supports:
* - Trackpad horizontal scroll (Mac/Windows)
* - Touch swipe (Mobile/Tablet)
* - Mouse horizontal scroll wheel
*/
import { browser } from '$app/environment';
export interface SwipeNavigationOptions {
/** Minimum pixels to trigger navigation (default: 80) */
threshold?: number;
/** Debounce time in ms for wheel events (default: 150) */
debounceMs?: number;
/** Disable swipe navigation temporarily */
disabled?: boolean;
}
const DEFAULT_THRESHOLD = 80;
const DEFAULT_DEBOUNCE_MS = 150;
/**
* Creates swipe/scroll navigation for a container element
*
* @param getElement - Function returning the target element
* @param onNext - Callback when swiping left (go to next period)
* @param onPrevious - Callback when swiping right (go to previous period)
* @param options - Configuration options
*
* @example
* ```svelte
* <script>
* import { useSwipeNavigation } from '$lib/composables';
* import { viewStore } from '$lib/stores/view.svelte';
*
* let containerRef: HTMLElement;
*
* useSwipeNavigation(
* () => containerRef,
* () => viewStore.goToNext(),
* () => viewStore.goToPrevious()
* );
* </script>
*
* <div bind:this={containerRef}>...</div>
* ```
*/
export function useSwipeNavigation(
getElement: () => HTMLElement | null,
onNext: () => void,
onPrevious: () => void,
options: SwipeNavigationOptions = {}
) {
if (!browser) return;
const threshold = options.threshold ?? DEFAULT_THRESHOLD;
const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
// Track accumulated wheel delta for trackpad detection
let accumulatedDelta = 0;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
// Touch tracking
let touchStartX = 0;
let touchStartY = 0;
let isTouching = false;
/**
* Handle wheel events (trackpad horizontal scroll)
*/
function handleWheel(e: WheelEvent) {
// Skip if disabled
if (options.disabled) return;
// Only handle horizontal scrolling (deltaX dominant)
// This distinguishes trackpad gestures from vertical scrolling
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
// Don't interfere with event dragging
const target = e.target as HTMLElement;
if (target.closest('[data-event-id]') || target.closest('[data-dragging]')) return;
// Prevent default scroll behavior for horizontal gestures
e.preventDefault();
// Accumulate horizontal delta
accumulatedDelta += e.deltaX;
// Reset accumulator after debounce period
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
accumulatedDelta = 0;
}, debounceMs);
// Check if threshold reached
if (accumulatedDelta > threshold) {
onNext();
accumulatedDelta = 0;
if (debounceTimer) clearTimeout(debounceTimer);
} else if (accumulatedDelta < -threshold) {
onPrevious();
accumulatedDelta = 0;
if (debounceTimer) clearTimeout(debounceTimer);
}
}
/**
* Handle touch start
*/
function handleTouchStart(e: TouchEvent) {
// Skip if disabled
if (options.disabled) return;
// Don't interfere with event dragging
const target = e.target as HTMLElement;
if (target.closest('[data-event-id]') || target.closest('[data-dragging]')) return;
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
isTouching = true;
}
/**
* Handle touch end
*/
function handleTouchEnd(e: TouchEvent) {
// Skip if disabled or wasn't tracking
if (options.disabled || !isTouching) return;
isTouching = false;
const touchEndX = e.changedTouches[0].clientX;
const touchEndY = e.changedTouches[0].clientY;
const deltaX = touchEndX - touchStartX;
const deltaY = touchEndY - touchStartY;
// Only trigger if horizontal movement is dominant and exceeds threshold
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > threshold) {
if (deltaX > 0) {
// Swiped right → go to previous
onPrevious();
} else {
// Swiped left → go to next
onNext();
}
}
}
/**
* Handle touch cancel
*/
function handleTouchCancel() {
isTouching = false;
}
// Setup and cleanup with $effect
$effect(() => {
const el = getElement();
if (!el) return;
// Add event listeners
el.addEventListener('wheel', handleWheel, { passive: false });
el.addEventListener('touchstart', handleTouchStart, { passive: true });
el.addEventListener('touchend', handleTouchEnd, { passive: true });
el.addEventListener('touchcancel', handleTouchCancel, { passive: true });
// Cleanup
return () => {
el.removeEventListener('wheel', handleWheel);
el.removeEventListener('touchstart', handleTouchStart);
el.removeEventListener('touchend', handleTouchEnd);
el.removeEventListener('touchcancel', handleTouchCancel);
if (debounceTimer) {
clearTimeout(debounceTimer);
}
};
});
}

View file

@ -0,0 +1,63 @@
/**
* Date Navigation Utilities
* Helper functions for calculating date offsets based on view type
*/
import type { CalendarViewType } from '@calendar/shared';
import {
addDays,
addWeeks,
addMonths,
addYears,
subDays,
subWeeks,
subMonths,
subYears,
} from 'date-fns';
/**
* Calculate a date offset based on the current view type
*
* @param date - The base date
* @param viewType - The current calendar view type
* @param offset - Number of periods to offset (-1 = previous, 1 = next)
* @returns The calculated date
*
* @example
* // Get previous week's date
* getOffsetDate(new Date(), 'week', -1)
*
* // Get next month's date
* getOffsetDate(new Date(), 'month', 1)
*/
export function getOffsetDate(date: Date, viewType: CalendarViewType, offset: number): Date {
switch (viewType) {
case 'day':
return offset > 0 ? addDays(date, offset) : subDays(date, Math.abs(offset));
case '5day':
return offset > 0 ? addDays(date, offset * 5) : subDays(date, Math.abs(offset) * 5);
case 'week':
return offset > 0 ? addWeeks(date, offset) : subWeeks(date, Math.abs(offset));
case '10day':
return offset > 0 ? addDays(date, offset * 10) : subDays(date, Math.abs(offset) * 10);
case '14day':
return offset > 0 ? addDays(date, offset * 14) : subDays(date, Math.abs(offset) * 14);
case 'month':
return offset > 0 ? addMonths(date, offset) : subMonths(date, Math.abs(offset));
case 'year':
return offset > 0 ? addYears(date, offset) : subYears(date, Math.abs(offset));
case 'agenda':
// Agenda moves by 7 days
return offset > 0 ? addDays(date, offset * 7) : subDays(date, Math.abs(offset) * 7);
default:
return offset > 0 ? addWeeks(date, offset) : subWeeks(date, Math.abs(offset));
}
}

View file

@ -8,12 +8,7 @@
import { authStore } from '$lib/stores/auth.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { isSidebarMode as sidebarModeStore } from '$lib/stores/navigation';
import WeekView from '$lib/components/calendar/WeekView.svelte';
import DayView from '$lib/components/calendar/DayView.svelte';
import MonthView from '$lib/components/calendar/MonthView.svelte';
import MultiDayView from '$lib/components/calendar/MultiDayView.svelte';
import YearView from '$lib/components/calendar/YearView.svelte';
import AgendaView from '$lib/components/calendar/AgendaView.svelte';
import ViewCarousel from '$lib/components/calendar/ViewCarousel.svelte';
import TodoSidebarSection from '$lib/components/calendar/TodoSidebarSection.svelte';
import QuickEventOverlay from '$lib/components/event/QuickEventOverlay.svelte';
import { CalendarViewSkeleton } from '$lib/components/skeletons';
@ -150,36 +145,8 @@
<div class="calendar-content">
{#if !initialized}
<CalendarViewSkeleton />
{:else if viewStore.viewType === 'day'}
<DayView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
{:else if viewStore.viewType === '5day'}
<MultiDayView
dayCount={5}
onQuickCreate={handleQuickCreate}
onEventClick={handleEventClick}
/>
{:else if viewStore.viewType === 'week'}
<WeekView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
{:else if viewStore.viewType === '10day'}
<MultiDayView
dayCount={10}
onQuickCreate={handleQuickCreate}
onEventClick={handleEventClick}
/>
{:else if viewStore.viewType === '14day'}
<MultiDayView
dayCount={14}
onQuickCreate={handleQuickCreate}
onEventClick={handleEventClick}
/>
{:else if viewStore.viewType === 'month'}
<MonthView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
{:else if viewStore.viewType === 'year'}
<YearView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
{:else if viewStore.viewType === 'agenda'}
<AgendaView onEventClick={handleEventClick} />
{:else}
<WeekView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
<ViewCarousel onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
{/if}
</div>
</div>