diff --git a/components/calendar/__tests__/calendar-agenda-view.test.tsx b/components/calendar/__tests__/calendar-agenda-view.test.tsx new file mode 100644 index 00000000..f56572a5 --- /dev/null +++ b/components/calendar/__tests__/calendar-agenda-view.test.tsx @@ -0,0 +1,172 @@ +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { CalendarAgendaView } from '../calendar-agenda-view'; +import type { CalendarEvent, Calendar } from '@/lib/jmap/types'; + +// The agenda is an infinite list (#759): a "show earlier" trigger at the +// top, a sentinel at the bottom that widens the window when it scrolls into +// view, and a "Today" anchor row only while today is part of the window. + +vi.mock('@/hooks/use-display-date-formatter', () => ({ + useDisplayDateFormatter: () => ({ dateTime: (d: Date) => d.toISOString().slice(0, 10) }), +})); + +type IOCallback = (entries: Array<{ isIntersecting: boolean }>) => void; +let observerCallbacks: IOCallback[] = []; +let observed: Element[] = []; + +class FakeIntersectionObserver { + constructor(cb: IOCallback) { observerCallbacks.push(cb); } + observe(el: Element) { observed.push(el); } + disconnect() {} + unobserve() {} +} + +const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[]; + +function makeEvent(id: string, start: string): CalendarEvent { + return { + id, + '@type': 'Event', + uid: id, + title: 'Event ' + id, + start, + duration: 'PT1H', + showWithoutTime: false, + calendarIds: { 'cal-1': true }, + } as unknown as CalendarEvent; +} + +type Props = React.ComponentProps; + +function baseProps(overrides: Partial = {}): Props { + return { + focus: { date: new Date(2026, 8, 9), nonce: 0 }, + windowKey: 'agenda:2026-09-09', + events: [], + calendars, + rangeStart: new Date(2026, 8, 9), + rangeEnd: new Date(2026, 9, 9), + onSelectEvent: vi.fn(), + ...overrides, + }; +} + +function renderView(overrides: Partial = {}) { + return render(); +} + +describe('CalendarAgendaView infinite scroll', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(2026, 8, 9, 10, 0, 0)); + observerCallbacks = []; + observed = []; + vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it('loads earlier days from the top button, once per fetch', () => { + const onExtendStart = vi.fn(); + const { rerender } = renderView({ onExtendStart }); + + fireEvent.click(screen.getByText('events.agenda_show_earlier')); + expect(onExtendStart).toHaveBeenCalledTimes(1); + + // A second request while the first is still outstanding is ignored ... + fireEvent.click(screen.getByText('events.agenda_show_earlier')); + expect(onExtendStart).toHaveBeenCalledTimes(1); + + // ... until the fetch has come and gone. + const wider = { rangeStart: new Date(2026, 7, 10), onExtendStart }; + rerender(); + rerender(); + fireEvent.click(screen.getByText('events.agenda_show_earlier')); + expect(onExtendStart).toHaveBeenCalledTimes(2); + }); + + it('loads earlier days when wheeling up while already at the top', () => { + const onExtendStart = vi.fn(); + const { container } = renderView({ onExtendStart }); + const scroller = container.firstElementChild as HTMLElement; + + fireEvent.wheel(scroller, { deltaY: 40 }); + expect(onExtendStart).not.toHaveBeenCalled(); + + fireEvent.wheel(scroller, { deltaY: -40 }); + expect(onExtendStart).toHaveBeenCalledTimes(1); + }); + + it('shows the window start instead of the button once the past limit is reached', () => { + renderView({ onExtendStart: undefined }); + expect(screen.queryByText('events.agenda_show_earlier')).toBeNull(); + expect(screen.getByText('events.agenda_range_start')).toBeInTheDocument(); + }); + + it('widens the future when the bottom sentinel becomes visible, once per fetch', () => { + const onExtendEnd = vi.fn(); + const { rerender } = renderView({ onExtendEnd }); + + // Before the first fetch for this window has completed, the rows on + // screen are stale (or absent) and a visible sentinel must not grow it. + expect(observed).toHaveLength(0); + rerender(); + rerender(); + + expect(observed).toContain(screen.getByTestId('agenda-bottom-sentinel')); + observerCallbacks.at(-1)?.([{ isIntersecting: true }]); + expect(onExtendEnd).toHaveBeenCalledTimes(1); + + // The window already grew but the fetch has not flipped the loading flag + // yet: a re-created observer that still sees the sentinel must not + // extend a second time. + const wider = { rangeEnd: new Date(2026, 10, 9), onExtendEnd }; + rerender(); + observerCallbacks.at(-1)?.([{ isIntersecting: true }]); + expect(onExtendEnd).toHaveBeenCalledTimes(1); + + // Once that fetch has finished, the next sighting extends again. + rerender(); + rerender(); + observerCallbacks.at(-1)?.([{ isIntersecting: true }]); + expect(onExtendEnd).toHaveBeenCalledTimes(2); + }); + + it('does not observe the sentinel while a fetch is in flight or at the future limit', () => { + renderView({ onExtendEnd: vi.fn(), isLoading: true }); + expect(observerCallbacks).toHaveLength(0); + cleanup(); + + renderView({ onExtendEnd: undefined }); + expect(observerCallbacks).toHaveLength(0); + expect(screen.getByText('events.agenda_range_end')).toBeInTheDocument(); + }); + + it('only anchors a "Today" row when today lies inside the loaded window', () => { + renderView(); + expect(screen.getByText('events.today_header')).toBeInTheDocument(); + cleanup(); + + renderView({ + focus: { date: new Date(2026, 0, 1), nonce: 1 }, + rangeStart: new Date(2026, 0, 1), + rangeEnd: new Date(2026, 1, 1), + events: [makeEvent('a', '2026-01-05T09:00:00')], + }); + expect(screen.queryByText('events.today_header')).toBeNull(); + expect(screen.getByText('Event a')).toBeInTheDocument(); + }); + + it('groups events by day in chronological order', () => { + renderView({ + events: [makeEvent('later', '2026-09-20T09:00:00'), makeEvent('sooner', '2026-09-12T09:00:00')], + }); + const titles = screen.getAllByText(/^Event /).map((el) => el.textContent); + expect(titles).toEqual(['Event sooner', 'Event later']); + }); +}); diff --git a/components/calendar/calendar-agenda-view.tsx b/components/calendar/calendar-agenda-view.tsx index d61979d8..61503704 100644 --- a/components/calendar/calendar-agenda-view.tsx +++ b/components/calendar/calendar-agenda-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useRef, useEffect, useCallback } from "react"; +import { useMemo, useRef, useCallback } from "react"; import { useTranslations } from "next-intl"; import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter"; import { format, isTomorrow, startOfDay } from "date-fns"; @@ -10,10 +10,11 @@ import { getEventColor } from "./event-card"; import { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; import { displayNow, isDisplayToday } from "@/lib/timezone"; import { getParticipantCount } from "@/lib/calendar-participants"; +import { useScrollWindow } from "@/hooks/use-scroll-window"; +import type { ScrollWindowViewProps } from "@/lib/calendar-scroll-window"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; -interface CalendarAgendaViewProps { - selectedDate: Date; +interface CalendarAgendaViewProps extends ScrollWindowViewProps { events: CalendarEvent[]; calendars: Calendar[]; onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void; @@ -30,9 +31,15 @@ interface DayGroup { } export function CalendarAgendaView({ - selectedDate, + focus, events, calendars, + rangeStart, + rangeEnd, + windowKey, + onExtendStart, + onExtendEnd, + isLoading = false, onSelectEvent, onHoverEvent, onHoverLeave, @@ -50,8 +57,8 @@ export function CalendarAgendaView({ return map; }, [calendars]); - const todayRef = useRef(null); const scrollContainerRef = useRef(null); + const bottomSentinelRef = useRef(null); const grouped = useMemo(() => { const sorted = [...events].sort((a, b) => @@ -79,37 +86,54 @@ export function CalendarAgendaView({ } catch { /* skip invalid dates */ } }); - // Always include today's date in the groups so the view has a "Today" anchor - const todayKey = format(displayNow(), "yyyy-MM-dd"); - if (!groupMap.has(todayKey)) { - const todayGroup = { date: startOfDay(displayNow()), dateKey: todayKey, events: [] as CalendarEvent[] }; + // Keep a "Today" row as an anchor, but only when today is actually part + // of the loaded window - otherwise it would claim there is nothing on a + // day that was never fetched. + const today = startOfDay(displayNow()); + const todayKey = format(today, "yyyy-MM-dd"); + if (!groupMap.has(todayKey) && today >= startOfDay(rangeStart) && today <= rangeEnd) { + const todayGroup = { date: today, dateKey: todayKey, events: [] as CalendarEvent[] }; groupMap.set(todayKey, todayGroup); groups.push(todayGroup); } groups.sort((a, b) => a.date.getTime() - b.date.getTime()); return groups; - }, [events]); + }, [events, rangeStart, rangeEnd]); - // Auto-scroll to today's section on mount and when selectedDate changes to today - const scrollToToday = useCallback(() => { - if (todayRef.current) { - todayRef.current.scrollIntoView({ block: "start" }); - } + // The focus row is the first day at or after the focused day: where the + // list starts out, and where "Today" brings the user back to. + const focusKey = format(focus.date, "yyyy-MM-dd"); + const focusIndex = grouped.findIndex((group) => group.dateKey >= focusKey); + const focusRowRef = useRef(null); + const scrollToFocus = useCallback(() => { + const el = scrollContainerRef.current; + const target = focusRowRef.current; + if (!el) return; + el.scrollTop = target + ? el.scrollTop + target.getBoundingClientRect().top - el.getBoundingClientRect().top + : 0; }, []); - useEffect(() => { - // Scroll to today on mount - const frame = requestAnimationFrame(scrollToToday); - return () => cancelAnimationFrame(frame); - }, [scrollToToday]); + const { requestStart, pendingSide } = useScrollWindow({ + scrollRef: scrollContainerRef, + axis: "vertical", + isLoading, + windowKey, + focusNonce: focus.nonce, + scrollToFocus, + onExtendStart, + onExtendEnd, + endSentinelRef: bottomSentinelRef, + contentKey: grouped, + anchorSelector: "[data-agenda-day]", + }); - useEffect(() => { - // Scroll to today when selectedDate changes to today - if (isDisplayToday(selectedDate)) { - scrollToToday(); - } - }, [selectedDate, scrollToToday]); + // Wheeling up while already at the top reaches for earlier days. Touch + // users (and anyone whose list is too short to scroll) have the button. + const handleWheel = useCallback((e: React.WheelEvent) => { + if (e.deltaY < 0 && e.currentTarget.scrollTop <= 0) requestStart(); + }, [requestStart]); const formatDateHeader = (date: Date): string => { if (isDisplayToday(date)) return t("events.today_header"); @@ -124,10 +148,40 @@ export function CalendarAgendaView({ return format(date, "HH:mm"); }; + const formatRangeDate = (date: Date): string => + intlFormatter.dateTime(date, { month: "short", day: "numeric", year: "numeric" }); + + const loadingPast = isLoading && pendingSide === "start"; + return ( -
- {grouped.map((group) => ( -
+
+
+ {onExtendStart ? ( + + ) : ( + {t("events.agenda_range_start", { date: formatRangeDate(rangeStart) })} + )} +
+ + {grouped.length === 0 && !isLoading && ( +
+ {t("events.no_events")} +
+ )} + + {grouped.map((group, index) => ( +
))} + +
+ {onExtendEnd + ? (isLoading && pendingSide === "end" ? t("events.agenda_loading") : " ") + : t("events.agenda_range_end", { date: formatRangeDate(rangeEnd) })} +
); } diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index c06c135d..215ca19d 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -5,9 +5,8 @@ import { useRouter } from "@/i18n/navigation"; import { useTranslations } from "next-intl"; import { Plus } from "lucide-react"; import { - startOfMonth, endOfMonth, startOfWeek, endOfWeek, addMonths, subMonths, addWeeks, subWeeks, addDays, subDays, - startOfDay, format, parseISO, + format, parseISO, } from "date-fns"; import { useCalendarStore } from "@/stores/calendar-store"; import { isCalendarViewMode } from "@/stores/calendar-store"; @@ -75,6 +74,12 @@ import { appPath, buildCalendarPath, parseCalendarPath, type CalendarDeepLink } import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/deep-link-handoff"; import { useDeepLinkUrl } from "@/hooks/use-deep-link-url"; import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect"; +import { useCalendarLocale } from "@/hooks/use-calendar-locale"; +import { + computeScrollWindow, fixedScrollWindowState, freshScrollWindowState, growScrollWindow, normalizeScrollWindowState, + scrollWindowContains, type CalendarFocus, type ScrollViewMode, type ScrollWindowOptions, + type ScrollWindowState, type ScrollWindowViewProps, +} from "@/lib/calendar-scroll-window"; type PendingScopeAction = | { type: "edit"; event: CalendarEvent; updates: Partial; sendScheduling?: boolean } @@ -344,42 +349,98 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) { } }, [showBirthdayCalendar]); // eslint-disable-line react-hooks/exhaustive-deps + // Free scrolling (#759): every view keeps one window of days around the + // day the user navigated to (the "focus"). Reaching an edge of the view + // widens that side and the whole window is refetched. Grid clicks only + // change the selection; navigation moves the focus and, when that leaves + // the loaded window, starts a fresh window there. + const { weekStartsOn, getMonthGridDays } = useCalendarLocale(); + const monthGridDaysRef = useRef(getMonthGridDays); + useEffect(() => { + monthGridDaysRef.current = getMonthGridDays; + }, [getMonthGridDays]); + const scrollWindowOptions = useMemo( + () => ({ weekStartsOn, monthGridDays: (date) => monthGridDaysRef.current(date) }), + [weekStartsOn], + ); + const scrollMode: ScrollViewMode | null = normalizedViewMode === "tasks" ? null : normalizedViewMode; + const [focus, setFocus] = useState(() => ({ date: selectedDate, nonce: 0 })); + const [visibleDate, setVisibleDate] = useState(null); + const [scrollWindowState, setScrollWindowState] = useState( + () => freshScrollWindowState(scrollMode ?? "month", selectedDate), + ); + // With free scrolling off, every view shows exactly one period around the + // focus and the edges never widen it. + const calendarFreeScroll = useSettingsStore((s) => s.calendarFreeScroll); + const focusKey = format(focus.date, "yyyy-MM-dd"); + const fixedWindowState = useMemo( + () => (scrollMode ? fixedScrollWindowState(scrollMode, parseISO(focusKey)) : null), + [scrollMode, focusKey], + ); + const windowState = !scrollMode + ? null + : calendarFreeScroll + ? normalizeScrollWindowState(scrollWindowState, scrollMode, focus.date) + : fixedWindowState; + useEffect(() => { + if (windowState && windowState !== scrollWindowState) setScrollWindowState(windowState); + }, [windowState, scrollWindowState]); + const scrollWindow = useMemo( + () => windowState ? computeScrollWindow(windowState, scrollWindowOptions) : null, + [windowState, scrollWindowOptions], + ); + const windowKey = windowState ? `${windowState.mode}:${windowState.anchorKey}` : ""; + + const jumpTo = useCallback((date: Date) => { + setSelectedDate(date); + setMiniMonth(date); + setVisibleDate(null); + setFocus((prev) => ({ date, nonce: prev.nonce + 1 })); + if (!scrollMode) return; + setScrollWindowState((prev) => { + const current = normalizeScrollWindowState(prev, scrollMode, date); + const loaded = computeScrollWindow(current, scrollWindowOptions); + return scrollWindowContains(loaded, scrollMode, date, scrollWindowOptions) + ? current + : freshScrollWindowState(scrollMode, date); + }); + }, [setSelectedDate, scrollMode, scrollWindowOptions]); + + const extendScrollWindow = useCallback((side: "before" | "after") => { + if (!scrollMode) return; + setScrollWindowState((prev) => growScrollWindow(normalizeScrollWindowState(prev, scrollMode, focus.date), side)); + }, [scrollMode, focus.date]); + const extendWindowStart = useCallback(() => extendScrollWindow("before"), [extendScrollWindow]); + const extendWindowEnd = useCallback(() => extendScrollWindow("after"), [extendScrollWindow]); + + // The views report the day they show as the user scrolls; the title and + // the mini calendar follow it, the selection does not. + const handleVisibleDateChange = useCallback((date: Date) => { + setVisibleDate(date); + setMiniMonth(date); + }, []); + useEffect(() => { + setVisibleDate(null); + }, [normalizedViewMode]); + + const scrollViewProps: ScrollWindowViewProps = { + focus, + rangeStart: scrollWindow?.start ?? focus.date, + rangeEnd: scrollWindow?.end ?? focus.date, + windowKey, + onExtendStart: calendarFreeScroll && scrollWindow?.canExtendStart ? extendWindowStart : undefined, + onExtendEnd: calendarFreeScroll && scrollWindow?.canExtendEnd ? extendWindowEnd : undefined, + isLoading: isLoadingEvents, + onVisibleDateChange: handleVisibleDateChange, + }; + const dateRange = useMemo(() => { - const d = selectedDate; - switch (normalizedViewMode) { - case "month": { - const ms = startOfMonth(d); - const me = endOfMonth(d); - return { - start: format(startOfWeek(ms, { weekStartsOn: firstDayOfWeek }), "yyyy-MM-dd'T'00:00:00"), - end: format(endOfWeek(me, { weekStartsOn: firstDayOfWeek }), "yyyy-MM-dd'T'23:59:59"), - }; - } - case "week": { - const ws = startOfWeek(d, { weekStartsOn: firstDayOfWeek }); - return { - start: format(ws, "yyyy-MM-dd'T'00:00:00"), - end: format(addDays(ws, 6), "yyyy-MM-dd'T'23:59:59"), - }; - } - case "day": - return { - start: format(d, "yyyy-MM-dd'T'00:00:00"), - end: format(d, "yyyy-MM-dd'T'23:59:59"), - }; - case "agenda": { - // Agenda always starts from today at the earliest - const today = startOfDay(displayNow()); - const agendaStart = d >= today ? d : today; - return { - start: format(agendaStart, "yyyy-MM-dd'T'00:00:00"), - end: format(addDays(agendaStart, 30), "yyyy-MM-dd'T'23:59:59"), - }; - } - case "tasks": - return null; - } - }, [selectedDate, normalizedViewMode, firstDayOfWeek]); + if (!scrollWindow) return null; + return { + start: format(scrollWindow.start, "yyyy-MM-dd'T'00:00:00"), + end: format(scrollWindow.end, "yyyy-MM-dd'T'23:59:59"), + }; + }, [scrollWindow]); // Fetch tasks when tasks view is active or when tasks are shown on calendar grid useEffect(() => { @@ -405,36 +466,37 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) { const fetchAllAccountsCalendarsFn = useCalendarStore((s) => s.fetchAllAccountsCalendars); const fetchAllAccountsEventsFn = useCalendarStore((s) => s.fetchAllAccountsEvents); + // The arrows step from what is on screen, which may have been scrolled + // away from the selected day. const navigatePrev = useCallback(() => { + const base = visibleDate ?? selectedDate; let next: Date; switch (normalizedViewMode) { - case "month": next = subMonths(selectedDate, 1); break; - case "week": next = subWeeks(selectedDate, 1); break; - case "day": next = subDays(selectedDate, 1); break; - case "agenda": next = subMonths(selectedDate, 1); break; + case "month": next = subMonths(base, 1); break; + case "week": next = subWeeks(base, 1); break; + case "day": next = subDays(base, 1); break; + case "agenda": next = subMonths(base, 1); break; case "tasks": return; } - setSelectedDate(next); - setMiniMonth(next); - }, [normalizedViewMode, selectedDate, setSelectedDate]); + jumpTo(next); + }, [normalizedViewMode, selectedDate, visibleDate, jumpTo]); const navigateNext = useCallback(() => { + const base = visibleDate ?? selectedDate; let next: Date; switch (normalizedViewMode) { - case "month": next = addMonths(selectedDate, 1); break; - case "week": next = addWeeks(selectedDate, 1); break; - case "day": next = addDays(selectedDate, 1); break; - case "agenda": next = addMonths(selectedDate, 1); break; + case "month": next = addMonths(base, 1); break; + case "week": next = addWeeks(base, 1); break; + case "day": next = addDays(base, 1); break; + case "agenda": next = addMonths(base, 1); break; case "tasks": return; } - setSelectedDate(next); - setMiniMonth(next); - }, [normalizedViewMode, selectedDate, setSelectedDate]); + jumpTo(next); + }, [normalizedViewMode, selectedDate, visibleDate, jumpTo]); const goToToday = useCallback(() => { - setSelectedDate(displayNow()); - setMiniMonth(displayNow()); - }, [setSelectedDate]); + jumpTo(displayNow()); + }, [jumpTo]); // Swipe navigation handlers for mobile const handleTouchStart = useCallback((e: ReactTouchEvent) => { @@ -444,8 +506,8 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) { const handleTouchEnd = useCallback((e: ReactTouchEvent) => { if (!touchStartRef.current || !isMobile) return; - // Week view has its own horizontal scroll, skip swipe navigation - if (normalizedViewMode === 'week') { touchStartRef.current = null; return; } + // Week and day views scroll sideways themselves, skip swipe navigation + if (normalizedViewMode === 'week' || normalizedViewMode === 'day') { touchStartRef.current = null; return; } const touch = e.changedTouches[0]; const dx = touch.clientX - touchStartRef.current.x; const dy = touch.clientY - touchStartRef.current.y; @@ -468,16 +530,19 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) { }, [isMobile, normalizedViewMode, navigatePrev, navigateNext]); const handleSelectDate = useCallback((date: Date) => { - setSelectedDate(date); - setMiniMonth(date); - // On mobile month view, tapping a date switches to day view + // On mobile month view, tapping a date switches to day view on that day. if (isMobile && normalizedViewMode === "month") { + jumpTo(date); setMobileReturnToMonth(true); setViewMode("day"); + } else { + // A click in the grid selects the day without moving the view. + setSelectedDate(date); + setMiniMonth(date); } // Close the narrow-pane sidebar overlay after the user picks a date. setNarrowSidebarOpen(false); - }, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]); + }, [setSelectedDate, jumpTo, isMobile, normalizedViewMode, setViewMode]); const navigateBackToMonth = useCallback(() => { setMobileReturnToMonth(false); @@ -485,9 +550,8 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) { }, [setViewMode]); const handleMiniMonthChange = useCallback((date: Date) => { - setMiniMonth(date); - setSelectedDate(date); - }, [setSelectedDate]); + jumpTo(date); + }, [jumpTo]); const openCreateModal = useCallback((date?: Date, endDate?: Date, allDay?: boolean) => { setEditEvent(null); @@ -609,7 +673,7 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) { const applyCalendarDeepLink = (link: CalendarDeepLink) => { if (link.kind === 'view') { setViewMode(link.view); - if (link.date) setSelectedDate(link.date); + if (link.date) jumpTo(link.date); return; } @@ -625,7 +689,7 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) { return; } const start = getEventStartDate(event); - if (start) setSelectedDate(start); + if (start) jumpTo(start); openEditModal(event); } catch (err) { debug.error('Failed to open calendar deep link:', err); @@ -768,9 +832,8 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) { return; } - setSelectedDate(eventDate); - setMiniMonth(eventDate); - }, [setSelectedDate]); + jumpTo(eventDate); + }, [jumpTo]); const handleSaveEvent = useCallback(async (data: Partial, sendSchedulingMessages?: boolean) => { if (!client) { toast.error(t("notifications.event_error")); return; } @@ -1329,6 +1392,7 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) { case "month": return ( i); +const FALLBACK_COL_WIDTH = 600; export function CalendarDayView({ selectedDate, + focus, events, calendars, + rangeStart, + rangeEnd, + windowKey, + onExtendStart, + onExtendEnd, + isLoading = false, + onVisibleDateChange, onSelectEvent, onHoverEvent, onHoverLeave, @@ -54,8 +65,51 @@ export function CalendarDayView({ // Grid days / event dates are display dates (local fields = wall-clock in // the user's zone); the app-wide formatter would shift them again (#755). const intlFormatter = useDisplayDateFormatter(); - const scrollRef = useRef(null); - const dayKey = format(selectedDate, "yyyy-MM-dd"); + // One scroll container for both axes (#759): the strip scrolls sideways, + // the hours scroll down, and the sticky header block and hour gutter stay + // put. (A nested vertical scroller would capture the gutter's stickiness.) + const rootRef = useRef(null); + const startSentinelRef = useRef(null); + const endSentinelRef = useRef(null); + const gutterWidth = isMobile ? 40 : 64; + + // One full-width column per loaded day (#759): the strip pages sideways + // through the days and widens at either end. + const days = useMemo( + () => eachDayOfInterval({ start: rangeStart, end: rangeEnd }), + [rangeStart, rangeEnd], + ); + const colCount = days.length; + + const measureColWidth = useCallback((root: HTMLElement | null) => { + if (!root || root.clientWidth <= gutterWidth) return FALLBACK_COL_WIDTH; + return root.clientWidth - gutterWidth; + }, [gutterWidth]); + const [colWidth, setColWidth] = useState(FALLBACK_COL_WIDTH); + useLayoutEffect(() => { + const root = rootRef.current; + if (!root) return; + setColWidth(measureColWidth(root)); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => setColWidth(measureColWidth(root))); + observer.observe(root); + return () => observer.disconnect(); + }, [measureColWidth]); + + // Every scroll offset is computed with the column width that is rendered. + // When that width changes (first measurement, resize) keep the same day + // in view. + const renderedColWidthRef = useRef(null); + useLayoutEffect(() => { + const root = rootRef.current; + const prev = renderedColWidthRef.current; + renderedColWidthRef.current = colWidth; + if (!root || prev === null || prev === colWidth) return; + setScrollStart(root, "horizontal", Math.round(getScrollStart(root, "horizontal") / prev) * colWidth); + }, [colWidth]); + + const stripWidth = gutterWidth + colCount * colWidth; + const columnsStyle = { gridTemplateColumns: `repeat(${colCount}, ${colWidth}px)` }; const calendarMap = useMemo(() => { const map = new Map(); @@ -63,42 +117,130 @@ export function CalendarDayView({ return map; }, [calendars]); - const { timedEvents, allDayEvents } = useMemo(() => { - const timed: CalendarEvent[] = []; - const allDay: CalendarEvent[] = []; + const eventsByDay = useMemo(() => { + const map = new Map(); + const bucket = (key: string) => { + let entry = map.get(key); + if (!entry) { + entry = { timed: [], allDay: [] }; + map.set(key, entry); + } + return entry; + }; events.forEach((ev) => { try { const { startDay, endDay } = getEventDayBounds(ev); - const selDay = new Date(selectedDate); selDay.setHours(0, 0, 0, 0); - - const spansThisDay = startDay.getTime() <= selDay.getTime() && endDay.getTime() >= selDay.getTime(); - if (!spansThisDay) return; - - if (ev.showWithoutTime || isTimedEventFullDayOnDate(ev, selectedDate)) allDay.push(ev); - else timed.push(ev); + const cursor = new Date(startDay); + while (cursor <= endDay) { + const key = format(cursor, "yyyy-MM-dd"); + if (ev.showWithoutTime || isTimedEventFullDayOnDate(ev, cursor)) bucket(key).allDay.push(ev); + else bucket(key).timed.push(ev); + cursor.setDate(cursor.getDate() + 1); + } } catch { /* skip invalid dates */ } }); - return { timedEvents: timed, allDayEvents: allDay }; - }, [events, selectedDate]); + return map; + }, [events]); - const dayTasks = useMemo(() => { - if (!tasks?.length) return []; - return tasks.filter(task => { - if (!task.due) return false; + const tasksByDay = useMemo(() => { + const map = new Map(); + if (!tasks?.length) return map; + for (const task of tasks) { + if (!task.due) continue; try { - return isSameDay(parseISO(task.due), selectedDate); - } catch { return false; } - }); - }, [tasks, selectedDate]); + const key = format(parseISO(task.due), "yyyy-MM-dd"); + const existing = map.get(key) || []; + existing.push(task); + map.set(key, existing); + } catch { /* skip */ } + } + return map; + }, [tasks]); + + // Column layouts are the costly part of a render; with months of columns + // they must not be redone on every scroll-driven re-render. + const layoutByDay = useMemo(() => { + const map = new Map>(); + for (const day of days) { + const key = format(day, "yyyy-MM-dd"); + map.set(key, layoutOverlappingEvents(eventsByDay.get(key)?.timed ?? [], day)); + } + return map; + }, [days, eventsByDay]); + + const hasAllDayArea = useMemo( + () => days.some((day) => { + const key = format(day, "yyyy-MM-dd"); + return (eventsByDay.get(key)?.allDay.length ?? 0) > 0 || (tasksByDay.get(key)?.length ?? 0) > 0; + }), + [days, eventsByDay, tasksByDay], + ); useEffect(() => { - if (scrollRef.current) { + if (rootRef.current) { const now = displayNow(); - scrollRef.current.scrollTop = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT); + rootRef.current.scrollTop = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT); } }, []); - const today = isDisplayToday(selectedDate); + const scrollToFocus = useCallback(() => { + const root = rootRef.current; + if (!root) return; + const index = Math.max(0, Math.min(colCount - 1, differenceInCalendarDays(focus.date, rangeStart))); + setScrollStart(root, "horizontal", index * colWidth); + }, [focus.date, colCount, rangeStart, colWidth]); + + useScrollWindow({ + scrollRef: rootRef, + axis: "horizontal", + isLoading, + windowKey, + focusNonce: focus.nonce, + scrollToFocus, + onExtendStart, + onExtendEnd, + startSentinelRef, + endSentinelRef, + contentKey: days, + anchorSelector: "[data-day]", + }); + + // Report the day in view so the title and mini calendar follow, and settle + // on a whole day once the scrolling has stopped. (CSS scroll snapping is + // not used: browsers re-snap on their own when columns are prepended, + // which would double the scroll correction.) + const visibleKeyRef = useRef(null); + const scrollFrameRef = useRef(null); + const snapTimerRef = useRef | null>(null); + const handleStripScroll = useCallback(() => { + if (snapTimerRef.current !== null) clearTimeout(snapTimerRef.current); + snapTimerRef.current = setTimeout(() => { + snapTimerRef.current = null; + const root = rootRef.current; + if (!root || colWidth <= 0) return; + const start = getScrollStart(root, "horizontal"); + const snapped = Math.round(start / colWidth) * colWidth; + if (Math.abs(snapped - start) > 1) scrollToStart(root, "horizontal", snapped); + }, 150); + if (scrollFrameRef.current !== null) return; + scrollFrameRef.current = requestAnimationFrame(() => { + scrollFrameRef.current = null; + const root = rootRef.current; + if (!root || colWidth <= 0) return; + const index = Math.max(0, Math.min(colCount - 1, Math.round(getScrollStart(root, "horizontal") / colWidth))); + const day = days[index]; + if (!day) return; + const key = dayKey(day); + if (key === visibleKeyRef.current) return; + visibleKeyRef.current = key; + onVisibleDateChange?.(day); + }); + }, [colWidth, colCount, days, onVisibleDateChange]); + useEffect(() => () => { + if (snapTimerRef.current !== null) clearTimeout(snapTimerRef.current); + if (scrollFrameRef.current !== null) cancelAnimationFrame(scrollFrameRef.current); + }, []); + const [nowMinutes, setNowMinutes] = useState(() => { const now = displayNow(); return now.getHours() * 60 + now.getMinutes(); @@ -137,256 +279,295 @@ export function CalendarDayView({ return format(new Date(2000, 0, 1, h), "HH:mm"); }; - const layouted = useMemo(() => layoutOverlappingEvents(timedEvents, selectedDate), [timedEvents, selectedDate]); + // Above every in-column overlay (events z-10, handles z-20, drag z-30) so + // columns scrolled past the start do not show through the gutter. + const gutterClass = cn("flex-shrink-0 sticky start-0 z-40 bg-background", isMobile ? "w-10" : "w-16"); return ( -
-
-

- {isMobile - ? intlFormatter.dateTime(selectedDate, { weekday: "short", month: "short", day: "numeric" }) - : intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" }) - } -

-
+
+
+
+
- {(allDayEvents.length > 0 || dayTasks.length > 0) && ( -
{ - if ((e.target as HTMLElement).closest("[data-calendar-event],button")) return; - onContextMenuEmpty(e, selectedDate, undefined, true); - } : undefined} - > - {allDayEvents.length > 0 && ( - <> -
{t("events.all_day")}
-
- {allDayEvents.map((ev) => { - const calId = getPrimaryCalendarId(ev); - return ( - onSelectEvent(ev, rect)} - onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} - onMouseLeave={onHoverLeave} - onContextMenu={onContextMenuEvent} - /> - ); - })} -
- - )} - {dayTasks.length > 0 && ( - <> -
0 && "mt-2")}>{t("tasks.label")}
-
- {dayTasks.map((task) => { - const isCompleted = task.progress === "completed"; - const cal = calendars.find(c => task.calendarIds[c.id]); - const color = cal?.color || "#3b82f6"; - return ( +
+
+
+ {days.map((day) => { + const key = format(day, "yyyy-MM-dd"); + const today = isDisplayToday(day); + const allDayEvents = eventsByDay.get(key)?.allDay ?? []; + const dayTasks = tasksByDay.get(key) ?? []; + return ( +
+
+

+ {isMobile + ? intlFormatter.dateTime(day, { weekday: "short", month: "short", day: "numeric" }) + : intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" }) + } +

+
+ + {hasAllDayArea && (
{ + if ((e.target as HTMLElement).closest("[data-calendar-event],button")) return; + onContextMenuEmpty(e, day, undefined, true); + } : undefined} > - - - {task.title || t("tasks.no_title")} - + {allDayEvents.length > 0 && ( + <> +
{t("events.all_day")}
+
+ {allDayEvents.map((ev) => { + const calId = getPrimaryCalendarId(ev); + return ( + onSelectEvent(ev, rect)} + onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} + onMouseLeave={onHoverLeave} + onContextMenu={onContextMenuEvent} + /> + ); + })} +
+ + )} + {dayTasks.length > 0 && ( + <> +
0 && "mt-2")}>{t("tasks.label")}
+
+ {dayTasks.map((task) => { + const isCompleted = task.progress === "completed"; + const cal = calendars.find(c => task.calendarIds[c.id]); + const color = cal?.color || "#3b82f6"; + return ( +
+ + + {task.title || t("tasks.no_title")} + +
+ ); + })} +
+ + )}
- ); - })} -
- - )} -
- )} - -
-
-
- {HOURS.map((h) => ( -
- {h > 0 && ( - - {formatHour(h)} - - )} -
- ))} + )} +
+ ); + })}
+
-
handleGridPointerDown(e, dayKey, selectedDate)} - onPointerMove={handleGridPointerMove} - onPointerUp={handleGridPointerUp} - onDragOver={(e) => handleColumnDragOver(e, dayKey)} - onDragLeave={handleColumnDragLeave} - onDrop={(e) => handleColumnDrop(e, selectedDate)} - > - {HOURS.map((h) => ( -
handleSlotClick(selectedDate, h)} - onDoubleClick={() => handleSlotDoubleClick(selectedDate, h)} - onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, selectedDate, h, false) : undefined} - className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors" - style={{ height: HOUR_HEIGHT }} - /> - ))} - - {layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => { - const durMin = Math.max(15, endMinutes - startMinutes); - const baseTop = (startMinutes / 60) * HOUR_HEIGHT; - const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT); - const isResizing = resizeVisual?.eventId === ev.id; - const top = isResizing ? resizeVisual!.topPx : baseTop; - const height = isResizing ? resizeVisual!.heightPx : baseHeight; - const calId = getPrimaryCalendarId(ev); - const leftPct = (column / totalColumns) * 100; - const widthPct = (1 / totalColumns) * 100; - - return ( +
+
+
+ {HOURS.map((h) => (
- onSelectEvent(ev, rect)} - onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} - onMouseLeave={onHoverLeave} - onContextMenu={onContextMenuEvent} - draggable - /> -
handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)} - onPointerMove={handleResizePointerMove} - onPointerUp={handleResizePointerUp} - > -
-
-
handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)} - onPointerMove={handleResizePointerMove} - onPointerUp={handleResizePointerUp} - > -
-
+ {h > 0 && ( + + {formatHour(h)} + + )}
- ); - })} + ))} +
+ +
+ {days.map((day) => { + const key = format(day, "yyyy-MM-dd"); + const today = isDisplayToday(day); + const layouted = layoutByDay.get(key) ?? []; - {today && ( -
-
-
-
-
-
- )} - - {quickCreate?.dayKey === dayKey && ( - - )} - - {dragCreate && ( -
-
- {formatSnapTime(dragCreate.startMinutes, timeFormat)} – {formatSnapTime(dragCreate.endMinutes, timeFormat)} -
-
- )} - - {dropTarget?.dayKey === dayKey && ( -
-
-
-
-
-
- {formatSnapTime(dropTarget.minutes, timeFormat)} -
-
- )} - - {pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, selectedDate) && ( - (() => { - const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes(); - const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes(); - const durationMin = Math.max(15, endMin - startMin); - const cal = calendars.find(c => c.id === pendingPreview.calendarId); - const color = cal?.color || "hsl(var(--primary))"; return (
handleGridPointerDown(e, key, day)} + onPointerMove={handleGridPointerMove} + onPointerUp={handleGridPointerUp} + onDragOver={(e) => handleColumnDragOver(e, key)} + onDragLeave={handleColumnDragLeave} + onDrop={(e) => handleColumnDrop(e, day)} > -
- {pendingPreview.title} -
-
- {formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)} -
+ {HOURS.map((h) => ( +
handleSlotClick(day, h)} + onDoubleClick={() => handleSlotDoubleClick(day, h)} + onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, h, false) : undefined} + className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors" + style={{ height: HOUR_HEIGHT }} + /> + ))} + + {layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => { + const durMin = Math.max(15, endMinutes - startMinutes); + const baseTop = (startMinutes / 60) * HOUR_HEIGHT; + const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT); + const isResizing = resizeVisual?.eventId === ev.id; + const top = isResizing ? resizeVisual!.topPx : baseTop; + const height = isResizing ? resizeVisual!.heightPx : baseHeight; + const calId = getPrimaryCalendarId(ev); + const leftPct = (column / totalColumns) * 100; + const widthPct = (1 / totalColumns) * 100; + + return ( +
+ onSelectEvent(ev, rect)} + onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} + onMouseLeave={onHoverLeave} + onContextMenu={onContextMenuEvent} + draggable + /> +
handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)} + onPointerMove={handleResizePointerMove} + onPointerUp={handleResizePointerUp} + > +
+
+
handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)} + onPointerMove={handleResizePointerMove} + onPointerUp={handleResizePointerUp} + > +
+
+
+ ); + })} + + {today && ( +
+
+
+
+
+
+ )} + + {quickCreate?.dayKey === key && ( + + )} + + {dragCreate?.dayKey === key && ( +
+
+ {formatSnapTime(dragCreate.startMinutes, timeFormat)} – {formatSnapTime(dragCreate.endMinutes, timeFormat)} +
+
+ )} + + {dropTarget?.dayKey === key && ( +
+
+
+
+
+
+ {formatSnapTime(dropTarget.minutes, timeFormat)} +
+
+ )} + + {pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, day) && ( + (() => { + const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes(); + const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes(); + const durationMin = Math.max(15, endMin - startMin); + const cal = calendars.find(c => c.id === pendingPreview.calendarId); + const color = cal?.color || "hsl(var(--primary))"; + return ( +
+
+ {pendingPreview.title} +
+
+ {formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)} +
+
+ ); + })() + )}
); - })() - )} + })} +
diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index 1843352f..ea69cd52 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -1,8 +1,8 @@ "use client"; -import { useMemo, useState, useCallback, type DragEvent } from "react"; +import { useMemo, useState, useCallback, useRef, useLayoutEffect, type DragEvent } from "react"; import { useTranslations } from "next-intl"; -import { format, parseISO } from "date-fns"; +import { format, parseISO, eachDayOfInterval, addDays } from "date-fns"; import { cn } from "@/lib/utils"; import { EventCard } from "./event-card"; import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils"; @@ -13,8 +13,10 @@ import { useSettingsStore } from "@/stores/settings-store"; import type { PendingEventPreview } from "./event-modal"; import { toast } from "@/stores/toast-store"; import { useCalendarLocale } from "@/hooks/use-calendar-locale"; +import { useScrollWindow } from "@/hooks/use-scroll-window"; +import { dayKey, parseDayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window"; -interface CalendarMonthViewProps { +interface CalendarMonthViewProps extends ScrollWindowViewProps { selectedDate: Date; events: CalendarEvent[]; calendars: Calendar[]; @@ -30,10 +32,21 @@ interface CalendarMonthViewProps { pendingPreview?: PendingEventPreview | null; } +/** Fraction of the viewport height at which the "current month" is sampled. */ +const VISIBLE_MONTH_SAMPLE = 0.4; + export function CalendarMonthView({ selectedDate, + focus, events, calendars, + rangeStart, + rangeEnd, + windowKey, + onExtendStart, + onExtendEnd, + isLoading = false, + onVisibleDateChange, onSelectDate, onSelectEvent, onHoverEvent, @@ -41,7 +54,6 @@ export function CalendarMonthView({ onContextMenuEvent, onContextMenuEmpty, onCreateAtTime, - firstDayOfWeek = 1, isMobile, pendingPreview, }: CalendarMonthViewProps) { @@ -53,8 +65,8 @@ export function CalendarMonthView({ const overlayTop = isMobile ? 34 : 30; const rowHeight = isMobile ? 18 : 22; const chipHeight = rowHeight - 2; + const baseRowMinHeight = isMobile ? 52 : 100; const { - weekStartsOn, dayHeaderKeys, getMonthGridDays, checkIsToday, @@ -62,12 +74,21 @@ export function CalendarMonthView({ checkIsSameDay, formatDayNumber, formatFullDate, + getMonth, + getYear, + monthLabelKeys, } = useCalendarLocale(); - const days = useMemo( - () => getMonthGridDays(selectedDate), - [selectedDate, getMonthGridDays], - ); + // The loaded window is a run of whole weeks (#759): scrolling moves through + // them continuously and the edges widen the window. + const weeks = useMemo(() => { + const days = eachDayOfInterval({ start: rangeStart, end: rangeEnd }); + const result: Date[][] = []; + for (let i = 0; i + 7 <= days.length; i += 7) { + result.push(days.slice(i, i + 7)); + } + return result; + }, [rangeStart, rangeEnd]); const calendarMap = useMemo(() => { const map = new Map(); @@ -94,14 +115,6 @@ export function CalendarMonthView({ return map; }, [events]); - const weeks = useMemo(() => { - const result: Date[][] = []; - for (let i = 0; i < days.length; i += 7) { - result.push(days.slice(i, i + 7)); - } - return result; - }, [days]); - const weekSegments = useMemo(() => { return weeks.map((week) => { const segments = buildWeekSegments(events, week); @@ -110,6 +123,85 @@ export function CalendarMonthView({ }); }, [events, weeks]); + const scrollRef = useRef(null); + const topSentinelRef = useRef(null); + const bottomSentinelRef = useRef(null); + + // Six rows fill the viewport, as a single month used to; taller rows grow + // with their chips. + const [viewportHeight, setViewportHeight] = useState(0); + useLayoutEffect(() => { + const el = scrollRef.current; + if (!el || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => setViewportHeight(el.clientHeight)); + observer.observe(el); + setViewportHeight(el.clientHeight); + return () => observer.disconnect(); + }, []); + const rowMinHeight = Math.max(baseRowMinHeight, Math.floor(viewportHeight / 6)); + + // The month the user is looking at: sampled a little above the middle of + // the viewport. It dims the other months' days and drives the title. + const [visibleMonthDate, setVisibleMonthDate] = useState(() => focus.date); + const visibleMonthRef = useRef(visibleMonthDate); + const scrollFrameRef = useRef(null); + + const sampleVisibleMonth = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + const sampleY = el.getBoundingClientRect().top + el.clientHeight * VISIBLE_MONTH_SAMPLE; + const rows = el.querySelectorAll("[data-week]"); + let hit: HTMLElement | null = null; + for (const row of rows) { + if (row.getBoundingClientRect().bottom >= sampleY) { hit = row; break; } + } + const weekKey = hit?.dataset.week; + if (!weekKey) return; + const midWeek = addDays(parseDayKey(weekKey), 3); + const current = visibleMonthRef.current; + if (getMonth(midWeek) === getMonth(current) && getYear(midWeek) === getYear(current)) return; + visibleMonthRef.current = midWeek; + setVisibleMonthDate(midWeek); + onVisibleDateChange?.(midWeek); + }, [getMonth, getYear, onVisibleDateChange]); + + const handleScroll = useCallback(() => { + if (scrollFrameRef.current !== null) return; + scrollFrameRef.current = requestAnimationFrame(() => { + scrollFrameRef.current = null; + sampleVisibleMonth(); + }); + }, [sampleVisibleMonth]); + + // Navigation puts the first week of the focused month at the top. + const scrollToFocus = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + const grid = getMonthGridDays(focus.date); + const targetKey = dayKey(grid[0] ?? focus.date); + const row = el.querySelector(`[data-week="${targetKey}"]`); + if (row) { + el.scrollTop += row.getBoundingClientRect().top - el.getBoundingClientRect().top; + } + visibleMonthRef.current = focus.date; + setVisibleMonthDate(focus.date); + }, [focus.date, getMonthGridDays]); + + useScrollWindow({ + scrollRef, + axis: "vertical", + isLoading, + windowKey, + focusNonce: focus.nonce, + scrollToFocus, + onExtendStart, + onExtendEnd, + startSentinelRef: topSentinelRef, + endSentinelRef: bottomSentinelRef, + contentKey: weekSegments, + anchorSelector: "[data-week]", + }); + const [dropDayKey, setDropDayKey] = useState(null); const handleCellDragOver = useCallback((e: DragEvent, dayKey: string) => { @@ -160,20 +252,28 @@ export function CalendarMonthView({ ))}
-
- {weekSegments.map(({ week, segments, rowCount }, wi) => ( -
+
+
+ {weekSegments.map(({ week, segments, rowCount }) => ( +
- {week.map((day) => { - const inMonth = checkIsSameMonth(day, selectedDate); + {week.map((day, dayIndex) => { + const inMonth = checkIsSameMonth(day, visibleMonthDate); const selected = checkIsSameDay(day, selectedDate); const today = checkIsToday(day); const key = format(day, "yyyy-MM-dd"); const dayEvents = eventsByDate.get(key) || []; const fullDateLabel = formatFullDate(day); + const previous = dayIndex > 0 ? week[dayIndex - 1] : addDays(day, -1); + const firstOfMonth = !checkIsSameMonth(day, previous); return (
-
+
+ {firstOfMonth && ( + + {t(`months.${monthLabelKeys[getMonth(day)]}`)} + + )} ))} +
); diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index d26f8686..921b1236 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -12,6 +12,8 @@ import { useCalendarLocale } from "@/hooks/use-calendar-locale"; interface CalendarToolbarProps { selectedDate: Date; + /** Day at the top / start of the scrolled view, when it differs from the selection. */ + visibleDate?: Date | null; viewMode: CalendarViewMode; onPrev: () => void; onNext: () => void; @@ -33,6 +35,7 @@ interface CalendarToolbarProps { export function CalendarToolbar({ selectedDate, + visibleDate, viewMode, onPrev, onNext, @@ -76,26 +79,31 @@ export function CalendarToolbar({ return () => document.removeEventListener("mousedown", handleClickOutside); }, [showCalendarDropdown]); + // The views scroll freely (#759): while the user has scrolled away from + // the selected day, the title describes what is on screen instead. + const titleDate = visibleDate ?? selectedDate; const getDateLabel = (): string => { switch (viewMode) { case "month": return isMobile - ? formatMonthYearShort(selectedDate) - : formatMonthYear(selectedDate); + ? formatMonthYearShort(titleDate) + : formatMonthYear(titleDate); case "week": { - const ws = startOfWeek(selectedDate, { weekStartsOn }); + // A reported visible date is the first column in view; the selected + // day is shown from the start of its week. + const ws = visibleDate ?? startOfWeek(selectedDate, { weekStartsOn }); return isMobile ? formatWeekRangeShort(ws) : formatWeekRange(ws); } case "day": return isMobile - ? formatFullDate(selectedDate) - : formatFullDate(selectedDate); + ? formatFullDate(titleDate) + : formatFullDate(titleDate); case "agenda": return isMobile - ? formatMonthYearShort(selectedDate) - : formatMonthYear(selectedDate); + ? formatMonthYearShort(titleDate) + : formatMonthYear(titleDate); case "tasks": return t("views.tasks"); } diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index 4ade2ec6..a67054c6 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -1,10 +1,10 @@ "use client"; -import { useMemo, useEffect, useRef, useState } from "react"; +import { useMemo, useEffect, useLayoutEffect, useRef, useState, useCallback } from "react"; import { useTranslations } from "next-intl"; import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter"; import { - startOfWeek, addDays, format, isSameDay, parseISO, + startOfWeek, format, isSameDay, parseISO, eachDayOfInterval, differenceInCalendarDays, } from "date-fns"; import { cn } from "@/lib/utils"; import { Check } from "lucide-react"; @@ -14,9 +14,11 @@ import { buildTimedFullDayWeekSegments, buildWeekSegmentsRaw, formatSnapTime, ge import { displayNow, isDisplayToday } from "@/lib/timezone"; import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types"; import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions"; +import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "@/hooks/use-scroll-window"; +import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window"; import type { PendingEventPreview } from "./event-modal"; -interface CalendarWeekViewProps { +interface CalendarWeekViewProps extends ScrollWindowViewProps { selectedDate: Date; events: CalendarEvent[]; calendars: Calendar[]; @@ -37,11 +39,21 @@ interface CalendarWeekViewProps { const HOUR_HEIGHT = 60; const HOURS = Array.from({ length: 24 }, (_, i) => i); +const MOBILE_COL_WIDTH = 120; +const MIN_COL_WIDTH = 80; export function CalendarWeekView({ selectedDate, + focus, events, calendars, + rangeStart, + rangeEnd, + windowKey, + onExtendStart, + onExtendEnd, + isLoading = false, + onVisibleDateChange, onSelectDate, onSelectEvent, onHoverEvent, @@ -60,14 +72,52 @@ export function CalendarWeekView({ // Grid days / event dates are display dates (local fields = wall-clock in // the user's zone); the app-wide formatter would shift them again (#755). const intlFormatter = useDisplayDateFormatter(); - const scrollRef = useRef(null); + // One scroll container for both axes (#759): the strip scrolls sideways, + // the hours scroll down, and the sticky header rows and hour gutter stay + // put. (A nested vertical scroller would capture the gutter's stickiness.) const rootRef = useRef(null); + const startSentinelRef = useRef(null); + const endSentinelRef = useRef(null); const weekStart = (firstDayOfWeek === 0 ? 0 : firstDayOfWeek === 6 ? 6 : 1) as 0 | 1 | 6; + const gutterWidth = isMobile ? 40 : 56; - const weekDays = useMemo(() => { - const start = startOfWeek(selectedDate, { weekStartsOn: weekStart }); - return Array.from({ length: 7 }, (_, i) => addDays(start, i)); - }, [selectedDate, weekStart]); + // One column per loaded day (#759). Seven columns fill the viewport on + // desktop; the strip scrolls sideways and widens at either end. + const days = useMemo( + () => eachDayOfInterval({ start: rangeStart, end: rangeEnd }), + [rangeStart, rangeEnd], + ); + const colCount = days.length; + + const measureColWidth = useCallback((root: HTMLElement | null) => { + if (isMobile || !root) return MOBILE_COL_WIDTH; + return Math.max(MIN_COL_WIDTH, Math.floor((root.clientWidth - gutterWidth) / 7)); + }, [isMobile, gutterWidth]); + const [colWidth, setColWidth] = useState(MOBILE_COL_WIDTH); + useLayoutEffect(() => { + const root = rootRef.current; + if (!root) return; + setColWidth(measureColWidth(root)); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => setColWidth(measureColWidth(root))); + observer.observe(root); + return () => observer.disconnect(); + }, [measureColWidth]); + + // Every scroll offset is computed with the column width that is rendered. + // When that width changes (first measurement, resize) keep the same day at + // the start. + const renderedColWidthRef = useRef(null); + useLayoutEffect(() => { + const root = rootRef.current; + const prev = renderedColWidthRef.current; + renderedColWidthRef.current = colWidth; + if (!root || prev === null || prev === colWidth) return; + setScrollStart(root, "horizontal", Math.round(getScrollStart(root, "horizontal") / prev) * colWidth); + }, [colWidth]); + + const stripWidth = gutterWidth + colCount * colWidth; + const columnsStyle = { gridTemplateColumns: `repeat(${colCount}, ${colWidth}px)` }; const calendarMap = useMemo(() => { const map = new Map(); @@ -97,24 +147,35 @@ export function CalendarWeekView({ return timed; }, [events]); + // Column layouts are the costly part of a render; with months of columns + // they must not be redone on every scroll-driven re-render. + const layoutByDay = useMemo(() => { + const map = new Map>(); + for (const day of days) { + const key = format(day, "yyyy-MM-dd"); + map.set(key, layoutOverlappingEvents(timedEvents.get(key) || [], day)); + } + return map; + }, [days, timedEvents]); + const allDaySegments = useMemo(() => { const explicitAllDay = buildWeekSegmentsRaw( events.filter((event) => event.showWithoutTime), - weekDays, + days, ); const timedFullDay = buildTimedFullDayWeekSegments( events.filter((event) => !event.showWithoutTime), - weekDays, + days, ); return packWeekSegments([...explicitAllDay, ...timedFullDay]); - }, [events, weekDays]); + }, [events, days]); const allDayRowCount = useMemo(() => { return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0); }, [allDaySegments]); - // Tasks grouped by day for the week + // Tasks grouped by day const tasksByDay = useMemo(() => { if (!tasks?.length) return new Map(); const map = new Map(); @@ -130,37 +191,87 @@ export function CalendarWeekView({ return map; }, [tasks]); - // Max tasks on any single day in this week + // Max tasks on any single loaded day const taskRowCount = useMemo(() => { let max = 0; - for (const day of weekDays) { + for (const day of days) { const key = format(day, "yyyy-MM-dd"); const count = tasksByDay.get(key)?.length ?? 0; if (count > max) max = count; } return max; - }, [tasksByDay, weekDays]); + }, [tasksByDay, days]); const hasAllDay = useMemo(() => { return allDaySegments.length > 0 || taskRowCount > 0; }, [allDaySegments, taskRowCount]); useEffect(() => { - if (scrollRef.current) { + if (rootRef.current) { const now = displayNow(); - scrollRef.current.scrollTop = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT); + rootRef.current.scrollTop = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT); } - // On mobile, scroll horizontally to center today's column - if (isMobile && rootRef.current) { - const todayIdx = weekDays.findIndex(d => isDisplayToday(d)); - if (todayIdx >= 0) { - const gutter = 40; - const colWidth = (rootRef.current.scrollWidth - gutter) / 7; - const target = gutter + todayIdx * colWidth - rootRef.current.clientWidth / 2 + colWidth / 2; - rootRef.current.scrollLeft = Math.max(0, target); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Navigation aligns the focused week (the focused day itself on mobile, + // where fewer columns fit) with the start of the viewport. + const scrollToFocus = useCallback(() => { + const root = rootRef.current; + if (!root) return; + const target = isMobile ? focus.date : startOfWeek(focus.date, { weekStartsOn: weekStart }); + const index = Math.max(0, Math.min(colCount - 1, differenceInCalendarDays(target, rangeStart))); + setScrollStart(root, "horizontal", index * colWidth); + }, [focus.date, isMobile, weekStart, colCount, rangeStart, colWidth]); + + useScrollWindow({ + scrollRef: rootRef, + axis: "horizontal", + isLoading, + windowKey, + focusNonce: focus.nonce, + scrollToFocus, + onExtendStart, + onExtendEnd, + startSentinelRef, + endSentinelRef, + contentKey: days, + anchorSelector: "[data-day]", + }); + + // Report the first column in view so the title and mini calendar follow, + // and settle on a column boundary once the scrolling has stopped. (CSS + // scroll snapping is not used: browsers re-snap on their own when columns + // are prepended, which would double the scroll correction.) + const visibleKeyRef = useRef(null); + const scrollFrameRef = useRef(null); + const snapTimerRef = useRef | null>(null); + const handleStripScroll = useCallback(() => { + if (snapTimerRef.current !== null) clearTimeout(snapTimerRef.current); + snapTimerRef.current = setTimeout(() => { + snapTimerRef.current = null; + const root = rootRef.current; + if (!root || colWidth <= 0) return; + const start = getScrollStart(root, "horizontal"); + const snapped = Math.round(start / colWidth) * colWidth; + if (Math.abs(snapped - start) > 1) scrollToStart(root, "horizontal", snapped); + }, 150); + if (scrollFrameRef.current !== null) return; + scrollFrameRef.current = requestAnimationFrame(() => { + scrollFrameRef.current = null; + const root = rootRef.current; + if (!root || colWidth <= 0) return; + const index = Math.max(0, Math.min(colCount - 1, Math.round(getScrollStart(root, "horizontal") / colWidth))); + const day = days[index]; + if (!day) return; + const key = dayKey(day); + if (key === visibleKeyRef.current) return; + visibleKeyRef.current = key; + onVisibleDateChange?.(day); + }); + }, [colWidth, colCount, days, onVisibleDateChange]); + useEffect(() => () => { + if (snapTimerRef.current !== null) clearTimeout(snapTimerRef.current); + if (scrollFrameRef.current !== null) cancelAnimationFrame(scrollFrameRef.current); }, []); const [nowMinutes, setNowMinutes] = useState(() => { @@ -201,34 +312,38 @@ export function CalendarWeekView({ return format(new Date(2000, 0, 1, h), "HH:mm"); }; - const colCount = 7; + // Above every in-column overlay (events z-10, handles z-20, drag z-30) so + // columns scrolled past the start do not show through the gutter. + const gutterClass = cn("flex-shrink-0 sticky start-0 z-40 bg-background", isMobile ? "w-10" : "w-14"); return (
-
{hasAllDay && ( +
+
+
+
+ {hasAllDay && (
{t("events.all_day")}
- {weekDays.map((day) => ( + {days.map((day) => (
onContextMenuEmpty(e, day, undefined, true) : undefined} /> ))} @@ -266,7 +381,7 @@ export function CalendarWeekView({ {/* Task chips in all-day area */} {taskRowCount > 0 && (
- {weekDays.map((day, dayIndex) => { + {days.map((day, dayIndex) => { const key = format(day, "yyyy-MM-dd"); const dayTasks = tasksByDay.get(key) || []; return dayTasks.map((task, taskIndex) => { @@ -310,9 +425,9 @@ export function CalendarWeekView({ )}
-
-
- {weekDays.map((day) => { +
+
+ {days.map((day) => { const todayCol = isDisplayToday(day); const selected = isSameDay(day, selectedDate); const fullLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" }); @@ -322,6 +437,7 @@ export function CalendarWeekView({ onClick={() => onSelectDate(day)} role="columnheader" aria-label={fullLabel} + data-day={dayKey(day)} className={cn( "text-center py-2 text-sm border-e border-border last:border-e-0 transition-colors touch-manipulation", "hover:bg-muted/50", @@ -344,9 +460,11 @@ export function CalendarWeekView({
-
+
+ +
-
+
{HOURS.map((h) => (
-
- {weekDays.map((day) => { +
+ {days.map((day) => { const key = format(day, "yyyy-MM-dd"); - const dayEvents = timedEvents.get(key) || []; const todayCol = isDisplayToday(day); - const layouted = layoutOverlappingEvents(dayEvents, day); + const layouted = layoutByDay.get(key) ?? []; return (
-
+
); } diff --git a/components/settings/calendar-settings.tsx b/components/settings/calendar-settings.tsx index c841d26f..3c1744ab 100644 --- a/components/settings/calendar-settings.tsx +++ b/components/settings/calendar-settings.tsx @@ -28,6 +28,7 @@ export function CalendarSettings() { const { showTimeInMonthView, showWeekNumbers, + calendarFreeScroll, enableCalendarTasks, showTasksOnCalendar, showBirthdayCalendar, @@ -91,6 +92,16 @@ export function CalendarSettings() { /> + + updateSetting('calendarFreeScroll', checked)} + /> + + ) => void; +let observerCallbacks: IOCallback[] = []; +let observed: Element[] = []; + +class FakeIntersectionObserver { + constructor(cb: IOCallback) { observerCallbacks.push(cb); } + observe(el: Element) { observed.push(el); } + disconnect() {} + unobserve() {} +} + +type HarnessProps = Omit & { + axis?: 'vertical' | 'horizontal'; + scrollHeight?: number; + onRender?: (api: ReturnType) => void; +}; + +function Harness({ axis = 'vertical', scrollHeight = 1000, onRender, ...options }: HarnessProps) { + const scrollRef = useRef(null); + const startRef = useRef(null); + const endRef = useRef(null); + const api = useScrollWindow({ ...options, axis, scrollRef, startSentinelRef: startRef, endSentinelRef: endRef }); + onRender?.(api); + return ( +
+
+
+
+
+ ); +} + +describe('useScrollWindow', () => { + beforeEach(() => { + observerCallbacks = []; + observed = []; + vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it('scrolls to the focus on mount, on navigation, and after the first load of a window', () => { + const scrollToFocus = vi.fn(); + const base = { isLoading: false, windowKey: 'w1', focusNonce: 0, scrollToFocus }; + const { rerender } = render(); + expect(scrollToFocus).toHaveBeenCalledTimes(1); + + rerender(); + rerender(); + expect(scrollToFocus).toHaveBeenCalledTimes(2); + + // Later loads (extensions) do not move the view ... + rerender(); + rerender(); + expect(scrollToFocus).toHaveBeenCalledTimes(2); + + // ... a navigation does, and so does the first load of a fresh window. + rerender(); + expect(scrollToFocus).toHaveBeenCalledTimes(3); + rerender(); + rerender(); + expect(scrollToFocus).toHaveBeenCalledTimes(4); + }); + + it('arms the sentinels only after the first load and fires each side once per fetch', () => { + const onExtendStart = vi.fn(); + const onExtendEnd = vi.fn(); + const base = { isLoading: false, windowKey: 'w1', focusNonce: 0, scrollToFocus: vi.fn(), onExtendStart, onExtendEnd }; + const { rerender } = render(); + expect(observed).toHaveLength(0); + + rerender(); + rerender(); + expect(observed).toHaveLength(2); + + // Two observers: start first, then end. + observerCallbacks[0]([{ isIntersecting: true }]); + expect(onExtendStart).toHaveBeenCalledTimes(1); + observerCallbacks[1]([{ isIntersecting: true }]); + expect(onExtendEnd).not.toHaveBeenCalled(); // one extension outstanding + + rerender(); + expect(observed).toHaveLength(2); // nothing new observed while loading + rerender(); + observerCallbacks.at(-1)?.([{ isIntersecting: true }]); + expect(onExtendEnd).toHaveBeenCalledTimes(1); + }); + + it('keeps the visible content in place while the start side grows', () => { + let api: ReturnType | null = null; + const base = { isLoading: false, windowKey: 'w1', focusNonce: 0, scrollToFocus: vi.fn(), onExtendStart: vi.fn(), onRender: (a: ReturnType) => { api = a; } }; + const { rerender, getByTestId } = render(); + const scroller = getByTestId('scroller'); + // jsdom has no layout: fake the scroll geometry. + let height = 1000; + Object.defineProperty(scroller, 'scrollHeight', { get: () => height, configurable: true }); + scroller.scrollTop = 200; + + rerender(); + rerender(); + api!.requestStart(); + expect(api!.pendingSide).toBeNull(); // state updates on the next render + + // The rows for the wider window render right away, before the fetch ... + height = 1600; + rerender(); + expect(scroller.scrollTop).toBe(800); + expect(api!.pendingSide).toBe('start'); + + // ... and grow again when their events arrive with the fetch. + rerender(); + height = 1650; + rerender(); + expect(scroller.scrollTop).toBe(850); + expect(api!.pendingSide).toBeNull(); + + // Growth that is not a start extension leaves the position alone. + height = 2000; + rerender(); + expect(scroller.scrollTop).toBe(850); + }); + + it('reads and writes the logical scroll start in both directions', () => { + const el = document.createElement('div'); + document.body.appendChild(el); // computed styles need an attached element + el.scrollLeft = 40; + expect(getScrollStart(el, 'horizontal')).toBe(40); + setScrollStart(el, 'horizontal', 70); + expect(el.scrollLeft).toBe(70); + el.style.direction = 'rtl'; + setScrollStart(el, 'horizontal', 30); + expect(el.scrollLeft).toBe(-30); + expect(getScrollStart(el, 'horizontal')).toBe(30); + setScrollStart(el, 'vertical', 12); + expect(getScrollStart(el, 'vertical')).toBe(12); + }); +}); diff --git a/hooks/use-scroll-window.ts b/hooks/use-scroll-window.ts new file mode 100644 index 00000000..6224148d --- /dev/null +++ b/hooks/use-scroll-window.ts @@ -0,0 +1,208 @@ +"use client"; + +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react"; + +export type ScrollAxis = "vertical" | "horizontal"; +export type ScrollWindowSide = "start" | "end"; + +/** Scroll offset from the logical start (handles RTL horizontal scrolling). */ +export function getScrollStart(el: HTMLElement, axis: ScrollAxis): number { + if (axis === "vertical") return el.scrollTop; + return getComputedStyle(el).direction === "rtl" ? -el.scrollLeft : el.scrollLeft; +} + +export function setScrollStart(el: HTMLElement, axis: ScrollAxis, value: number): void { + if (axis === "vertical") { + el.scrollTop = value; + return; + } + el.scrollLeft = getComputedStyle(el).direction === "rtl" ? -value : value; +} + +/** Smoothly scrolls to a logical start offset (RTL-aware). */ +export function scrollToStart(el: HTMLElement, axis: ScrollAxis, value: number): void { + if (axis === "vertical") { + el.scrollTo({ top: value, behavior: "smooth" }); + return; + } + el.scrollTo({ left: getComputedStyle(el).direction === "rtl" ? -value : value, behavior: "smooth" }); +} + +function scrollSize(el: HTMLElement, axis: ScrollAxis): number { + return axis === "vertical" ? el.scrollHeight : el.scrollWidth; +} + +/** Distance of an element's leading edge from the container's leading edge. */ +function startOffset(el: Element, container: HTMLElement, axis: ScrollAxis): number { + const rect = el.getBoundingClientRect(); + const box = container.getBoundingClientRect(); + if (axis === "vertical") return rect.top - box.top; + return getComputedStyle(container).direction === "rtl" ? box.right - rect.right : rect.left - box.left; +} + +/** The first anchorable element that is still (partly) inside the viewport. */ +function findAnchor(container: HTMLElement, selector: string, axis: ScrollAxis): { el: Element; offset: number } | null { + const box = container.getBoundingClientRect(); + for (const el of container.querySelectorAll(selector)) { + const rect = el.getBoundingClientRect(); + const inside = axis === "vertical" + ? rect.bottom > box.top + : (getComputedStyle(container).direction === "rtl" ? rect.left < box.right : rect.right > box.left); + if (inside) return { el, offset: startOffset(el, container, axis) }; + } + return null; +} + +export interface UseScrollWindowOptions { + scrollRef: RefObject; + axis: ScrollAxis; + /** True while the events for the current range are being fetched. */ + isLoading: boolean; + /** Changes when a fresh window is started; its first fetch re-arms the edges. */ + windowKey: string; + /** Changes when the user navigates; the view then scrolls to the focus. */ + focusNonce: number; + /** Puts the focused day into view. Called on navigation and once the rows for a fresh window arrived. */ + scrollToFocus: () => void; + /** Widen the window at the start; omit when the limit is reached. */ + onExtendStart?: () => void; + /** Widen the window at the end; omit when the limit is reached. */ + onExtendEnd?: () => void; + /** Sentinel observed for automatic start extension (optional: views may trigger it themselves). */ + startSentinelRef?: RefObject; + endSentinelRef?: RefObject; + /** Re-observe the sentinels when the rendered content changed. */ + contentKey?: unknown; + /** + * Selector for the rows/columns that can serve as the scroll anchor while + * the start side grows. Without it the total scroll size is used, which + * is only right when nothing but the prepended content changes height. + */ + anchorSelector?: string; + /** Distance in px before a sentinel enters the viewport at which it counts as visible. */ + margin?: number; +} + +/** + * The scroll mechanics shared by the calendar views (#759): one outstanding + * extension at a time, the visible content kept in place when rows or + * columns are prepended, the focused day scrolled into view on navigation, + * and the edge sentinels armed only once the first fetch of a window landed + * (before that the rows on screen belong to the previous window). + */ +export function useScrollWindow({ + scrollRef, + axis, + isLoading, + windowKey, + focusNonce, + scrollToFocus, + onExtendStart, + onExtendEnd, + startSentinelRef, + endSentinelRef, + contentKey, + anchorSelector, + margin = 300, +}: UseScrollWindowOptions) { + const pendingRef = useRef(null); + const [pendingSide, setPendingSide] = useState(null); + const wasLoadingRef = useRef(isLoading); + const loadedRef = useRef(false); + const lastSizeRef = useRef(null); + const anchorRef = useRef<{ el: Element; offset: number } | null>(null); + const scrollToFocusRef = useRef(scrollToFocus); + + useLayoutEffect(() => { + scrollToFocusRef.current = scrollToFocus; + }); + + useLayoutEffect(() => { + loadedRef.current = false; + }, [windowKey]); + + useLayoutEffect(() => { + pendingRef.current = null; + setPendingSide(null); + scrollToFocusRef.current(); + }, [focusNonce]); + + // While an extension at the start is outstanding, every content change + // (the rows or columns added right away, then the events that fill them) + // grows the scroll size ahead of what the user is looking at: shift the + // scroll position by the same amount so it stays put. (Browser scroll + // anchoring is disabled on the containers so this is not applied twice.) + useLayoutEffect(() => { + const el = scrollRef.current; + if (!el) return; + const size = scrollSize(el, axis); + const previous = lastSizeRef.current; + lastSizeRef.current = size; + if (previous === null || pendingRef.current !== "start") return; + const anchor = anchorRef.current; + if (anchor && anchor.el.isConnected) { + const drift = startOffset(anchor.el, el, axis) - anchor.offset; + if (drift !== 0) setScrollStart(el, axis, getScrollStart(el, axis) + drift); + return; + } + if (size !== previous) setScrollStart(el, axis, getScrollStart(el, axis) + size - previous); + }, [contentKey, axis, scrollRef]); + + // The fetch an extension triggered has finished: data and the loading flag + // land in the same render. The first load of a fresh window also puts the + // focus into view, as the rows were not there when navigation happened. + useLayoutEffect(() => { + const finished = wasLoadingRef.current && !isLoading; + wasLoadingRef.current = isLoading; + if (!finished) return; + pendingRef.current = null; + setPendingSide(null); + if (!loadedRef.current) { + loadedRef.current = true; + scrollToFocusRef.current(); + } + }, [isLoading]); + + const requestStart = useCallback(() => { + if (!onExtendStart || isLoading || pendingRef.current) return; + const el = scrollRef.current; + if (el) { + lastSizeRef.current = scrollSize(el, axis); + anchorRef.current = anchorSelector ? findAnchor(el, anchorSelector, axis) : null; + } + pendingRef.current = "start"; + setPendingSide("start"); + onExtendStart(); + }, [onExtendStart, isLoading, scrollRef, axis, anchorSelector]); + + const requestEnd = useCallback(() => { + if (!onExtendEnd || isLoading || pendingRef.current) return; + pendingRef.current = "end"; + setPendingSide("end"); + onExtendEnd(); + }, [onExtendEnd, isLoading]); + + // Sentinels load more as soon as they come into view. The observer is + // recreated whenever the content or loading state changes so a sentinel + // that stays visible (short content) keeps filling until the limit. + useEffect(() => { + const root = scrollRef.current; + if (!root || isLoading || !loadedRef.current || typeof IntersectionObserver === "undefined") return; + const targets: Array<[HTMLElement, () => void]> = []; + if (onExtendStart && startSentinelRef?.current) targets.push([startSentinelRef.current, requestStart]); + if (onExtendEnd && endSentinelRef?.current) targets.push([endSentinelRef.current, requestEnd]); + if (targets.length === 0) return; + const rootMargin = axis === "vertical" ? `${margin}px 0px` : `0px ${margin}px`; + const observers = targets.map(([target, request]) => { + const observer = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) request(); + }, { root, rootMargin }); + observer.observe(target); + return observer; + }); + return () => observers.forEach((observer) => observer.disconnect()); + // contentKey is a dependency on purpose: new content means new geometry. + }, [contentKey, isLoading, onExtendStart, onExtendEnd, requestStart, requestEnd, axis, margin, scrollRef, startSentinelRef, endSentinelRef]); + + return { requestStart, requestEnd, pendingSide }; +} diff --git a/lib/__tests__/calendar-scroll-window.test.ts b/lib/__tests__/calendar-scroll-window.test.ts new file mode 100644 index 00000000..d92741f4 --- /dev/null +++ b/lib/__tests__/calendar-scroll-window.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest'; +import { + baseRange, computeScrollWindow, fixedScrollWindowState, freshScrollWindowState, growScrollWindow, + normalizeScrollWindowState, scrollWindowContains, SCROLL_WINDOW_MAX, SCROLL_WINDOW_STEP, + type ScrollWindowOptions, +} from '../calendar-scroll-window'; + +// #759: every calendar view keeps one window of days around the focused +// day; edges double, navigation inside the window does not reset it. + +const opts: ScrollWindowOptions = { weekStartsOn: 1 }; +const key = (d: Date) => d.toISOString().slice(0, 10); +// Local-midnight dates render in the test's timezone; compare by local fields. +const local = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + +describe('baseRange', () => { + it('is the day itself, the week, the month grid, or 30 days for the agenda', () => { + const wed = new Date(2026, 8, 9); // Wednesday + expect(local(baseRange('day', wed, opts).start)).toBe('2026-09-09'); + expect(local(baseRange('day', wed, opts).end)).toBe('2026-09-09'); + expect(local(baseRange('week', wed, opts).start)).toBe('2026-09-07'); + expect(local(baseRange('week', wed, opts).end)).toBe('2026-09-13'); + expect(local(baseRange('month', wed, opts).start)).toBe('2026-08-31'); + expect(local(baseRange('month', wed, opts).end)).toBe('2026-10-04'); + expect(local(baseRange('agenda', wed, opts).start)).toBe('2026-09-09'); + expect(local(baseRange('agenda', wed, opts).end)).toBe('2026-10-09'); + }); + + it('uses the locale month grid when one is supplied', () => { + const grid = [new Date(2026, 7, 24), new Date(2026, 7, 25), new Date(2026, 9, 11)]; + const range = baseRange('month', new Date(2026, 8, 9), { ...opts, monthGridDays: () => grid }); + expect(local(range.start)).toBe('2026-08-24'); + expect(local(range.end)).toBe('2026-10-11'); + }); +}); + +describe('computeScrollWindow', () => { + it('starts as the base range plus the initial days after it, snapped to whole weeks', () => { + const win = computeScrollWindow(freshScrollWindowState('month', new Date(2026, 8, 9)), opts); + expect(local(win.start)).toBe('2026-08-31'); + // 2026-10-04 + 30 days = 2026-11-03 (Tuesday) -> end of that week + expect(local(win.end)).toBe('2026-11-08'); + expect(win.canExtendStart).toBe(true); + expect(win.canExtendEnd).toBe(true); + }); + + it('is exactly one period when free scrolling is off (agenda keeps its 30 days)', () => { + const month = computeScrollWindow(fixedScrollWindowState('month', new Date(2026, 8, 9)), opts); + expect(local(month.start)).toBe('2026-08-31'); + expect(local(month.end)).toBe('2026-10-04'); + const week = computeScrollWindow(fixedScrollWindowState('week', new Date(2026, 8, 9)), opts); + expect([local(week.start), local(week.end)]).toEqual(['2026-09-07', '2026-09-13']); + const day = computeScrollWindow(fixedScrollWindowState('day', new Date(2026, 8, 9)), opts); + expect([local(day.start), local(day.end)]).toEqual(['2026-09-09', '2026-09-09']); + const agenda = computeScrollWindow(fixedScrollWindowState('agenda', new Date(2026, 8, 9)), opts); + expect([local(agenda.start), local(agenda.end)]).toEqual(['2026-09-09', '2026-10-09']); + }); + + it('does not snap the day view to weeks', () => { + const win = computeScrollWindow(freshScrollWindowState('day', new Date(2026, 8, 9)), opts); + expect(local(win.start)).toBe('2026-09-09'); + expect(local(win.end)).toBe('2026-10-09'); + }); + + it('reports the limits once a side reached its maximum', () => { + const state = { ...freshScrollWindowState('week', new Date(2026, 8, 9)), before: SCROLL_WINDOW_MAX.week }; + expect(computeScrollWindow(state, opts).canExtendStart).toBe(false); + expect(computeScrollWindow(state, opts).canExtendEnd).toBe(true); + }); +}); + +describe('growScrollWindow', () => { + it('doubles a side from the first step up to the cap and then stays put', () => { + let state = freshScrollWindowState('agenda', new Date(2026, 8, 9)); + const seen: number[] = []; + for (let i = 0; i < 8; i++) { + state = growScrollWindow(state, 'before'); + seen.push(state.before); + } + expect(seen).toEqual([SCROLL_WINDOW_STEP, 60, 120, 240, 365, 365, 365, 365]); + const capped = growScrollWindow(state, 'before'); + expect(capped).toBe(state); + }); +}); + +describe('normalizeScrollWindowState / scrollWindowContains', () => { + it('starts over when the view mode changed', () => { + const month = freshScrollWindowState('month', new Date(2026, 8, 9)); + expect(normalizeScrollWindowState(month, 'month', new Date(2026, 0, 1))).toBe(month); + const week = normalizeScrollWindowState(month, 'week', new Date(2026, 0, 1)); + expect(week.mode).toBe('week'); + expect(week.anchorKey).toBe('2026-01-01'); + }); + + it('knows whether a navigation target is already loaded', () => { + const win = computeScrollWindow(freshScrollWindowState('month', new Date(2026, 8, 9)), opts); + expect(scrollWindowContains(win, 'month', new Date(2026, 9, 15), opts)).toBe(true); // October grid ends Nov 1 + expect(scrollWindowContains(win, 'month', new Date(2026, 10, 15), opts)).toBe(false); // November grid ends Dec 6 + expect(scrollWindowContains(win, 'week', new Date(2026, 7, 31), opts)).toBe(true); + expect(scrollWindowContains(win, 'week', new Date(2026, 7, 30), opts)).toBe(false); + }); + + it('keys the anchor by calendar day', () => { + expect(freshScrollWindowState('day', new Date(2026, 8, 9, 23, 59)).anchorKey).toBe('2026-09-09'); + expect(key(new Date(Date.UTC(2026, 8, 9)))).toBe('2026-09-09'); + }); +}); diff --git a/lib/calendar-scroll-window.ts b/lib/calendar-scroll-window.ts new file mode 100644 index 00000000..ede2395e --- /dev/null +++ b/lib/calendar-scroll-window.ts @@ -0,0 +1,167 @@ +import { addDays, subDays, startOfWeek, endOfWeek, startOfDay, startOfMonth, endOfMonth, format } from "date-fns"; + +/** + * The calendar views scroll freely (#759): each keeps one window of days + * around the day the user navigated to. Reaching an edge of the rendered + * range widens that side and the whole window is refetched, so the store + * never has to merge partial results. A navigation that lands inside the + * window just scrolls; one that leaves it starts a fresh window there. + */ + +export type ScrollViewMode = "month" | "week" | "day" | "agenda"; + +export interface DayRange { + start: Date; + end: Date; +} + +export interface ScrollWindowState { + mode: ScrollViewMode; + /** yyyy-MM-dd of the day the window was started from. */ + anchorKey: string; + /** Days added before the base range of the anchor. */ + before: number; + /** Days added after the base range of the anchor. */ + after: number; +} + +export interface ScrollWindow extends DayRange { + canExtendStart: boolean; + canExtendEnd: boolean; +} + +/** The day the user navigated to; `nonce` changes on every navigation. */ +export interface CalendarFocus { + date: Date; + nonce: number; +} + +/** Props every freely scrolling calendar view receives from the app. */ +export interface ScrollWindowViewProps { + focus: CalendarFocus; + /** Loaded window (whole days, inclusive). */ + rangeStart: Date; + rangeEnd: Date; + /** Changes whenever a fresh window is started. */ + windowKey: string; + /** Widen the window at the start; omitted once the limit is reached. */ + onExtendStart?: () => void; + /** Widen the window at the end; omitted once the limit is reached. */ + onExtendEnd?: () => void; + isLoading?: boolean; + /** Reports the day at the top / start of the viewport as the user scrolls. */ + onVisibleDateChange?: (date: Date) => void; +} + +export interface ScrollWindowOptions { + weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6; + /** Locale-aware month grid (Jalali support); Gregorian when omitted. */ + monthGridDays?: (date: Date) => Date[]; +} + +/** First growth step in days; every further step doubles the side. */ +export const SCROLL_WINDOW_STEP = 30; + +/** Furthest a side may grow, in days. Time grids render one column per day. */ +export const SCROLL_WINDOW_MAX: Record = { + month: 365, + agenda: 365, + week: 180, + day: 180, +}; + +/** Days loaded after the base range before the user scrolls anywhere. */ +const INITIAL_AFTER: Record = { + month: SCROLL_WINDOW_STEP, + agenda: SCROLL_WINDOW_STEP, + week: SCROLL_WINDOW_STEP, + day: SCROLL_WINDOW_STEP, +}; + +export function dayKey(date: Date): string { + return format(date, "yyyy-MM-dd"); +} + +export function freshScrollWindowState(mode: ScrollViewMode, anchor: Date): ScrollWindowState { + return { mode, anchorKey: dayKey(anchor), before: 0, after: INITIAL_AFTER[mode] }; +} + +/** + * The window with free scrolling turned off: exactly the base range (one + * month/week/day, the agenda's 30 days). Never grows; navigation always + * starts over here. + */ +export function fixedScrollWindowState(mode: ScrollViewMode, anchor: Date): ScrollWindowState { + return { mode, anchorKey: dayKey(anchor), before: 0, after: 0 }; +} + +/** The range a view shows for a date when nothing has been scrolled yet. */ +export function baseRange(mode: ScrollViewMode, date: Date, opts: ScrollWindowOptions): DayRange { + const day = startOfDay(date); + switch (mode) { + case "day": + return { start: day, end: day }; + case "week": + return { + start: startOfWeek(day, { weekStartsOn: opts.weekStartsOn }), + end: endOfWeek(day, { weekStartsOn: opts.weekStartsOn }), + }; + case "month": { + const grid = opts.monthGridDays?.(day); + if (grid && grid.length > 0) return { start: startOfDay(grid[0]), end: startOfDay(grid[grid.length - 1]) }; + return { + start: startOfWeek(startOfMonth(day), { weekStartsOn: opts.weekStartsOn }), + end: endOfWeek(endOfMonth(day), { weekStartsOn: opts.weekStartsOn }), + }; + } + case "agenda": + return { start: day, end: addDays(day, SCROLL_WINDOW_STEP) }; + } +} + +/** Ensures the state belongs to the view mode; otherwise starts over at the anchor. */ +export function normalizeScrollWindowState( + state: ScrollWindowState, + mode: ScrollViewMode, + anchor: Date, +): ScrollWindowState { + return state.mode === mode ? state : freshScrollWindowState(mode, anchor); +} + +export function computeScrollWindow( + state: ScrollWindowState, + opts: ScrollWindowOptions, +): ScrollWindow { + const anchor = parseDayKey(state.anchorKey); + const base = baseRange(state.mode, anchor, opts); + let start = subDays(base.start, state.before); + let end = addDays(base.end, state.after); + if (state.mode === "month" || state.mode === "week") { + start = startOfWeek(start, { weekStartsOn: opts.weekStartsOn }); + end = endOfWeek(end, { weekStartsOn: opts.weekStartsOn }); + } + const max = SCROLL_WINDOW_MAX[state.mode]; + return { + start: startOfDay(start), + end: startOfDay(end), + canExtendStart: state.before < max, + canExtendEnd: state.after < max, + }; +} + +export function growScrollWindow(state: ScrollWindowState, side: "before" | "after"): ScrollWindowState { + const max = SCROLL_WINDOW_MAX[state.mode]; + const grown = Math.min(max, Math.max(SCROLL_WINDOW_STEP, state[side] * 2)); + return grown === state[side] ? state : { ...state, [side]: grown }; +} + +/** True when the view's base range for `date` is already loaded. */ +export function scrollWindowContains(window: DayRange, mode: ScrollViewMode, date: Date, opts: ScrollWindowOptions): boolean { + const base = baseRange(mode, date, opts); + return base.start.getTime() >= window.start.getTime() && base.end.getTime() <= window.end.getTime(); +} + +export function parseDayKey(key: string): Date { + const [y, m, d] = key.split("-").map(Number); + return new Date(y, m - 1, d); +} diff --git a/lib/calendar-utils.ts b/lib/calendar-utils.ts index 15d31c9b..ab9c0434 100644 --- a/lib/calendar-utils.ts +++ b/lib/calendar-utils.ts @@ -175,46 +175,42 @@ export function buildWeekSegments(events: CalendarEvent[], weekDays: Date[]): Ca return packWeekSegments(buildWeekSegmentsRaw(events, weekDays)); } +/** + * Segments for timed events that cover whole days (midnight to midnight) + * within `weekDays`, which must be consecutive days. The days an event + * covers in full always form one run, so each event is resolved from its + * own bounds instead of being tested against every day - the freely + * scrolling views hand in months of days at a time (#759). + */ export function buildTimedFullDayWeekSegments(events: CalendarEvent[], weekDays: Date[]): CalendarWeekSegment[] { if (weekDays.length === 0) return []; - const rawSegments = events.flatMap((event) => { - const fullDayIndices = weekDays - .map((day, index) => (isTimedEventFullDayOnDate(event, day) ? index : -1)) - .filter((index) => index >= 0); + const firstDay = startOfDay(weekDays[0]); + const lastDay = startOfDay(weekDays[weekDays.length - 1]); - if (fullDayIndices.length === 0) { - return []; - } - - const segments: CalendarWeekSegment[] = []; - let rangeStart = fullDayIndices[0]; - let previousIndex = fullDayIndices[0]; - - const pushSegment = (startIndex: number, endIndex: number) => { - const startDay = weekDays[startIndex]; - const endDay = weekDays[endIndex]; - segments.push({ - event, - startIndex, - span: endIndex - startIndex + 1, - row: -1, - continuesBefore: isTimedEventFullDayOnDate(event, addDays(startDay, -1)), - continuesAfter: isTimedEventFullDayOnDate(event, addDays(endDay, 1)), - }); - }; - - for (let index = 1; index < fullDayIndices.length; index++) { - const currentIndex = fullDayIndices[index]; - if (currentIndex !== previousIndex + 1) { - pushSegment(rangeStart, previousIndex); - rangeStart = currentIndex; - } - previousIndex = currentIndex; - } + const rawSegments = events.flatMap((event) => { + if (event.showWithoutTime) return []; + const eventStart = getEventStartDate(event); + const eventEnd = getEventEndDate(event); + const startDay = startOfDay(eventStart); + // First day whose midnight is not before the event, last day whose next + // midnight is not after it. + const firstFull = eventStart.getTime() === startDay.getTime() ? startDay : addDays(startDay, 1); + const lastFull = addDays(startOfDay(eventEnd), -1); + if (lastFull < firstFull) return []; + + const segmentStart = firstFull < firstDay ? firstDay : firstFull; + const segmentEnd = lastFull > lastDay ? lastDay : lastFull; + if (segmentStart > segmentEnd) return []; - pushSegment(rangeStart, previousIndex); - return segments; + return [{ + event, + startIndex: differenceInCalendarDays(segmentStart, firstDay), + span: differenceInCalendarDays(segmentEnd, segmentStart) + 1, + row: -1, + continuesBefore: firstFull < segmentStart, + continuesAfter: lastFull > segmentEnd, + } satisfies CalendarWeekSegment]; }); return packWeekSegments(rawSegments); diff --git a/lib/timezone.ts b/lib/timezone.ts index e497b735..0394a88b 100644 --- a/lib/timezone.ts +++ b/lib/timezone.ts @@ -29,12 +29,20 @@ import { useSettingsStore } from "@/stores/settings-store"; export const AUTO_TIME_ZONE = "auto"; /** The zone the browser reports; `UTC` when detection fails (SSR, old engines). */ +// Resolving the zone builds an Intl formatter, which costs tens of +// microseconds; the calendar grids convert every event for every day they +// show, so the answer is kept. The browser zone does not change within a +// page's lifetime in practice. +let browserTimeZone: string | null = null; + export function getBrowserTimeZone(): string { + if (browserTimeZone) return browserTimeZone; try { - return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { - return "UTC"; + browserTimeZone = "UTC"; } + return browserTimeZone; } const validityCache = new Map(); diff --git a/locales/ar/common.json b/locales/ar/common.json index 35837242..7ce66df4 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -2776,6 +2776,10 @@ "duplicate": "تكرار", "today_header": "اليوم", "tomorrow_header": "غدًا", + "agenda_show_earlier": "عرض الأحداث السابقة", + "agenda_loading": "جارٍ التحميل…", + "agenda_range_start": "الأحداث من {date}", + "agenda_range_end": "الأحداث حتى {date}", "export_ics": "التصدير كملف ‎.ics", "copy_title": "نسخ العنوان", "copy_link": "نسخ رابط الاجتماع", @@ -2931,6 +2935,8 @@ "show_time_in_month_view_desc": "عرض أوقات الأحداث في عرض التقويم الشهري", "show_week_numbers": "إظهار أرقام الأسابيع", "show_week_numbers_desc": "عرض أرقام الأسابيع في التقويم المصغّر", + "calendar_free_scroll": "تمرير حر", + "calendar_free_scroll_desc": "التمرير باستمرار عبر الأشهر والأسابيع والأيام بدلاً من فترة واحدة في كل مرة", "enable_tasks": "تفعيل المهام", "enable_tasks_desc": "إظهار عرض للمهام في التقويم لإدارة قوائم المهام", "show_tasks_on_calendar": "إظهار المهام في التقويم", diff --git a/locales/ca/common.json b/locales/ca/common.json index 2bfd8300..d3bceb0d 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Duplica", "today_header": "Avui", "tomorrow_header": "Demà", + "agenda_show_earlier": "Mostra esdeveniments anteriors", + "agenda_loading": "Carregant…", + "agenda_range_start": "Esdeveniments des del {date}", + "agenda_range_end": "Esdeveniments fins al {date}", "export_ics": "Exporta com a .ics", "copy_title": "Copia el títol", "copy_link": "Copia l'enllaç de la reunió", @@ -2931,6 +2935,8 @@ "show_time_in_month_view_desc": "Mostra l'hora dels esdeveniments a la vista mensual del calendari. En pantalles petites, això mostra entrades completes d'esdeveniment en lloc de punts.", "show_week_numbers": "Mostra els números de setmana", "show_week_numbers_desc": "Mostra els números de setmana al minicalendari", + "calendar_free_scroll": "Desplaçament lliure", + "calendar_free_scroll_desc": "Desplaça't de manera contínua per mesos, setmanes i dies en lloc d'un període cada vegada", "enable_tasks": "Activa les tasques", "enable_tasks_desc": "Mostra una vista de tasques al calendari per gestionar pendents", "show_tasks_on_calendar": "Mostra les tasques al calendari", diff --git a/locales/cs/common.json b/locales/cs/common.json index 4a82ae05..7367db8e 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Duplikovat", "today_header": "Dnes", "tomorrow_header": "Zítra", + "agenda_show_earlier": "Zobrazit dřívější události", + "agenda_loading": "Načítání…", + "agenda_range_start": "Události od {date}", + "agenda_range_end": "Události do {date}", "export_ics": "Exportovat jako .ics", "copy_title": "Kopírovat název", "copy_link": "Kopírovat odkaz na schůzku", @@ -2931,6 +2935,8 @@ "show_time_in_month_view_desc": "Zobrazovat časy událostí v měsíčním zobrazení kalendáře", "show_week_numbers": "Zobrazit čísla týdnů", "show_week_numbers_desc": "Zobrazovat čísla týdnů v minikalendáři", + "calendar_free_scroll": "Volné posouvání", + "calendar_free_scroll_desc": "Plynule procházet měsíce, týdny a dny místo jednoho období najednou", "enable_tasks": "Povolit úkoly", "enable_tasks_desc": "Zobrazovat zobrazení úkolů v kalendáři pro správu úkolů", "show_tasks_on_calendar": "Zobrazit úkoly v kalendáři", diff --git a/locales/da/common.json b/locales/da/common.json index 47dbdbb9..1a60ef4e 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Duplikér", "today_header": "I dag", "tomorrow_header": "I morgen", + "agenda_show_earlier": "Vis tidligere begivenheder", + "agenda_loading": "Indlæser…", + "agenda_range_start": "Begivenheder fra {date}", + "agenda_range_end": "Begivenheder til {date}", "export_ics": "Eksportér som .ics", "copy_title": "Kopier titel", "copy_link": "Kopier mødelink", @@ -2931,6 +2935,8 @@ "show_time_in_month_view_desc": "Vis begivenhedstider i månedskalendervisningen", "show_week_numbers": "Vis ugenumre", "show_week_numbers_desc": "Vis ugenumre i minikalenderen", + "calendar_free_scroll": "Fri rulning", + "calendar_free_scroll_desc": "Rul fortløbende gennem måneder, uger og dage i stedet for én periode ad gangen", "enable_tasks": "Aktivér opgaver", "enable_tasks_desc": "Vis en opgavevisning i kalenderen til styring af gøremål", "show_tasks_on_calendar": "Vis opgaver på kalender", diff --git a/locales/de/common.json b/locales/de/common.json index 8b0f875c..22ccb659 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Duplizieren", "today_header": "Heute", "tomorrow_header": "Morgen", + "agenda_show_earlier": "Frühere Termine anzeigen", + "agenda_loading": "Wird geladen…", + "agenda_range_start": "Termine ab {date}", + "agenda_range_end": "Termine bis {date}", "export_ics": "Als .ics exportieren", "copy_title": "Titel kopieren", "copy_link": "Meeting-Link kopieren", @@ -2931,6 +2935,8 @@ "show_time_in_month_view_desc": "Ereigniszeiten in der Monatskalenderansicht anzeigen", "show_week_numbers": "Kalenderwochen anzeigen", "show_week_numbers_desc": "Kalenderwochen im Minikalender anzeigen", + "calendar_free_scroll": "Freies Scrollen", + "calendar_free_scroll_desc": "Fortlaufend durch Monate, Wochen und Tage scrollen statt nur einen Zeitraum auf einmal anzuzeigen", "enable_tasks": "Aufgaben aktivieren", "enable_tasks_desc": "Eine Aufgabenansicht im Kalender zum Verwalten von To-dos anzeigen", "show_tasks_on_calendar": "Aufgaben im Kalender anzeigen", diff --git a/locales/en/common.json b/locales/en/common.json index 1d2125d7..cf57ed10 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Duplicate", "today_header": "Today", "tomorrow_header": "Tomorrow", + "agenda_show_earlier": "Show earlier events", + "agenda_loading": "Loading…", + "agenda_range_start": "Showing events from {date}", + "agenda_range_end": "Showing events until {date}", "export_ics": "Export as .ics", "copy_title": "Copy title", "copy_link": "Copy meeting link", @@ -2931,6 +2935,8 @@ "show_time_in_month_view_desc": "Display event times in the month calendar view. On small screens this shows full event entries instead of dots.", "show_week_numbers": "Show week numbers", "show_week_numbers_desc": "Display week numbers in the mini-calendar", + "calendar_free_scroll": "Free scrolling", + "calendar_free_scroll_desc": "Scroll continuously through months, weeks and days instead of one period at a time", "enable_tasks": "Enable tasks", "enable_tasks_desc": "Show a tasks view in the calendar for managing to-dos", "show_tasks_on_calendar": "Show tasks on calendar", diff --git a/locales/es/common.json b/locales/es/common.json index ff9346b4..f9e47b09 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Duplicar", "today_header": "Hoy", "tomorrow_header": "Mañana", + "agenda_show_earlier": "Mostrar eventos anteriores", + "agenda_loading": "Cargando…", + "agenda_range_start": "Eventos desde el {date}", + "agenda_range_end": "Eventos hasta el {date}", "export_ics": "Exportar como .ics", "copy_title": "Copiar título", "copy_link": "Copiar enlace de reunión", @@ -2931,6 +2935,8 @@ "show_time_in_month_view_desc": "Mostrar las horas de los eventos en la vista mensual del calendario", "show_week_numbers": "Mostrar números de semana", "show_week_numbers_desc": "Mostrar los números de semana en el minicalendario", + "calendar_free_scroll": "Desplazamiento libre", + "calendar_free_scroll_desc": "Desplazarse de forma continua por meses, semanas y días en lugar de un periodo cada vez", "enable_tasks": "Activar tareas", "enable_tasks_desc": "Mostrar una vista de tareas en el calendario para gestionar tareas pendientes", "show_tasks_on_calendar": "Mostrar tareas en el calendario", diff --git a/locales/fa/common.json b/locales/fa/common.json index 5b5ae3c7..30ae8071 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -2776,6 +2776,10 @@ "duplicate": "کپی", "today_header": "امروز", "tomorrow_header": "فردا", + "agenda_show_earlier": "نمایش رویدادهای قبلی", + "agenda_loading": "در حال بارگذاری…", + "agenda_range_start": "رویدادها از {date}", + "agenda_range_end": "رویدادها تا {date}", "export_ics": "خروجی .ics", "copy_title": "کپی عنوان", "copy_link": "کپی لینک جلسه", @@ -2931,6 +2935,8 @@ "show_time_in_month_view_desc": "نمایش زمان رویدادها در نمای ماه تقویم", "show_week_numbers": "نمایش شماره هفته", "show_week_numbers_desc": "نمایش شماره هفته در تقویم کوچک", + "calendar_free_scroll": "پیمایش آزاد", + "calendar_free_scroll_desc": "پیمایش پیوسته در ماه‌ها، هفته‌ها و روزها به جای یک بازه در هر بار", "enable_tasks": "فعال کردن وظایف", "enable_tasks_desc": "نمایش نمای وظایف در تقویم برای مدیریت کارها", "show_tasks_on_calendar": "نمایش وظایف روی تقویم", diff --git a/locales/fr/common.json b/locales/fr/common.json index 911c96e3..b35db2ee 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Dupliquer", "today_header": "Aujourd'hui", "tomorrow_header": "Demain", + "agenda_show_earlier": "Afficher les événements précédents", + "agenda_loading": "Chargement…", + "agenda_range_start": "Événements à partir du {date}", + "agenda_range_end": "Événements jusqu'au {date}", "export_ics": "Exporter en .ics", "copy_title": "Copier le titre", "copy_link": "Copier le lien de réunion", @@ -2931,6 +2935,8 @@ "show_time_in_month_view_desc": "Afficher les heures des événements dans la vue mensuelle du calendrier", "show_week_numbers": "Afficher les numéros de semaine", "show_week_numbers_desc": "Afficher les numéros de semaine dans le mini-calendrier", + "calendar_free_scroll": "Défilement libre", + "calendar_free_scroll_desc": "Faire défiler les mois, semaines et jours en continu au lieu d'une période à la fois", "enable_tasks": "Activer les tâches", "enable_tasks_desc": "Afficher une vue des tâches dans le calendrier pour gérer les choses à faire", "show_tasks_on_calendar": "Afficher les tâches dans le calendrier", diff --git a/locales/he/common.json b/locales/he/common.json index 39cedd7c..8e408669 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -2700,6 +2700,10 @@ "duplicate": "שכפול", "today_header": "היום", "tomorrow_header": "מחר", + "agenda_show_earlier": "הצגת אירועים קודמים", + "agenda_loading": "טוען…", + "agenda_range_start": "אירועים מ-{date}", + "agenda_range_end": "אירועים עד {date}", "export_ics": "יצא כ .ics", "copy_title": "עותק כותרת", "copy_link": "עותק קישור פגישה", @@ -2855,6 +2859,8 @@ "show_time_in_month_view_desc": "הצג זמני אירועים בתצוגת לוח השנה של החודש", "show_week_numbers": "הצג מספרי שבוע", "show_week_numbers_desc": "הצג מספרי שבוע במיני-לוח שנה", + "calendar_free_scroll": "גלילה חופשית", + "calendar_free_scroll_desc": "גלילה רציפה בין חודשים, שבועות וימים במקום תקופה אחת בכל פעם", "enable_tasks": "אפשר משימות", "enable_tasks_desc": "הצג תצוגת משימות בלוח השנה לניהול מטלות", "show_tasks_on_calendar": "הצג משימות בלוח השנה", diff --git a/locales/hu/common.json b/locales/hu/common.json index 9496a03a..6366ece1 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Duplikálás", "today_header": "Ma", "tomorrow_header": "Holnap", + "agenda_show_earlier": "Korábbi események megjelenítése", + "agenda_loading": "Betöltés…", + "agenda_range_start": "Események ettől: {date}", + "agenda_range_end": "Események eddig: {date}", "export_ics": "Exportálás .ics-ként", "copy_title": "Cím másolása", "copy_link": "Találkozó link másolása", @@ -2931,6 +2935,8 @@ "show_time_in_month_view_desc": "Események időpontjának mutatása a havi naptár nézetben", "show_week_numbers": "Hétszámok mutatása", "show_week_numbers_desc": "Hétszámok mutatása a mini naptárban", + "calendar_free_scroll": "Szabad görgetés", + "calendar_free_scroll_desc": "Folyamatos görgetés hónapok, hetek és napok között egyetlen időszak helyett", "enable_tasks": "Feladatok engedélyezése", "enable_tasks_desc": "Feladatnézet mutatása a naptárban a teendők kezeléséhez", "show_tasks_on_calendar": "Feladatok mutatása a naptárban", diff --git a/locales/it/common.json b/locales/it/common.json index af6b6fc2..f6a3017a 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Duplica", "today_header": "Oggi", "tomorrow_header": "Domani", + "agenda_show_earlier": "Mostra eventi precedenti", + "agenda_loading": "Caricamento…", + "agenda_range_start": "Eventi dal {date}", + "agenda_range_end": "Eventi fino al {date}", "export_ics": "Esporta come .ics", "copy_title": "Copia titolo", "copy_link": "Copia link riunione", @@ -2931,6 +2935,8 @@ "show_time_in_month_view_desc": "Visualizza gli orari degli eventi nella vista mensile del calendario", "show_week_numbers": "Mostra numeri di settimana", "show_week_numbers_desc": "Mostra i numeri di settimana nel mini-calendario", + "calendar_free_scroll": "Scorrimento libero", + "calendar_free_scroll_desc": "Scorri in modo continuo tra mesi, settimane e giorni invece di un periodo alla volta", "enable_tasks": "Abilita attività", "enable_tasks_desc": "Mostra una vista attività nel calendario per gestire le cose da fare", "show_tasks_on_calendar": "Mostra attività nel calendario", diff --git a/locales/ja/common.json b/locales/ja/common.json index 6bfe43af..147cc1fa 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -2776,6 +2776,10 @@ "duplicate": "複製", "today_header": "今日", "tomorrow_header": "明日", + "agenda_show_earlier": "以前の予定を表示", + "agenda_loading": "読み込み中…", + "agenda_range_start": "{date}以降の予定", + "agenda_range_end": "{date}までの予定", "export_ics": ".icsとしてエクスポート", "copy_title": "タイトルをコピー", "copy_link": "会議リンクをコピー", @@ -2931,6 +2935,8 @@ "show_time_in_month_view_desc": "月カレンダー表示でイベントの時刻を表示する", "show_week_numbers": "週番号を表示", "show_week_numbers_desc": "ミニカレンダーに週番号を表示する", + "calendar_free_scroll": "自由スクロール", + "calendar_free_scroll_desc": "1つの期間ずつではなく、月・週・日を連続してスクロールします", "enable_tasks": "タスクを有効化", "enable_tasks_desc": "カレンダーにタスク表示を追加して、ToDoを管理します", "show_tasks_on_calendar": "カレンダーにタスクを表示", diff --git a/locales/ko/common.json b/locales/ko/common.json index aa5ac359..5acb161f 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -2776,6 +2776,10 @@ "duplicate": "복제", "today_header": "오늘", "tomorrow_header": "내일", + "agenda_show_earlier": "이전 일정 보기", + "agenda_loading": "불러오는 중…", + "agenda_range_start": "{date}부터의 일정", + "agenda_range_end": "{date}까지의 일정", "export_ics": ".ics로 내보내기", "copy_title": "제목 복사", "copy_link": "회의 링크 복사", @@ -2931,6 +2935,8 @@ "show_time_in_month_view_desc": "월간 캘린더에서도 일정 시간을 표시해요", "show_week_numbers": "주 차 번호 표시", "show_week_numbers_desc": "미니 캘린더에 1년 중 몇 주 차인지 표시해요", + "calendar_free_scroll": "자유 스크롤", + "calendar_free_scroll_desc": "한 번에 한 기간씩 보는 대신 월, 주, 일을 연속으로 스크롤합니다", "enable_tasks": "할 일 사용", "enable_tasks_desc": "캘린더에 할 일을 관리할 수 있는 메뉴를 추가해요", "show_tasks_on_calendar": "캘린더에 할 일 표시", diff --git a/locales/lv/common.json b/locales/lv/common.json index 7a1fd61f..a2133d77 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -2775,6 +2775,10 @@ "duplicate": "Dublēt", "today_header": "Šodien", "tomorrow_header": "Rīt", + "agenda_show_earlier": "Rādīt agrākos notikumus", + "agenda_loading": "Ielādē…", + "agenda_range_start": "Notikumi no {date}", + "agenda_range_end": "Notikumi līdz {date}", "export_ics": "Eksportēt kā .ics", "copy_title": "Kopēt nosaukumu", "copy_link": "Kopēt sapulces saiti", @@ -2930,6 +2934,8 @@ "show_time_in_month_view_desc": "Rādīt pasākuma laiku mēneša kalendārā", "show_week_numbers": "Rādīt nedēļu numurus", "show_week_numbers_desc": "Rādīt nedēļu numurus mini kalendārā", + "calendar_free_scroll": "Brīva ritināšana", + "calendar_free_scroll_desc": "Nepārtraukti ritināt cauri mēnešiem, nedēļām un dienām, nevis pa vienam periodam", "enable_tasks": "Iespējot uzdevumus", "enable_tasks_desc": "Rādīt uzdevumu sadaļu kalendārā", "show_tasks_on_calendar": "Rādīt uzdevumus kalendārā", diff --git a/locales/mn/common.json b/locales/mn/common.json index d11c0ce1..28e281cd 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Давхардсан", "today_header": "Өнөөдөр", "tomorrow_header": "Маргааш", + "agenda_show_earlier": "Өмнөх үйл явдлуудыг харуулах", + "agenda_loading": "Ачаалж байна…", + "agenda_range_start": "{date}-с хойшхи үйл явдлууд", + "agenda_range_end": "{date} хүртэлх үйл явдлууд", "export_ics": ".ics", "copy_title": "Гарчиг хуулах", "copy_link": "Уулзалтын холбоосыг хуулах", @@ -2931,6 +2935,8 @@ "show_time_in_month_view_desc": "Сарын хуанлийн харагдац дээр үйл явдлын цагийг харуулах. Жижиг дэлгэц дээр энэ нь цэгийн оронд үйл явдлын бүрэн оруулгуудыг харуулдаг.", "show_week_numbers": "Долоо хоногийн дугаарыг харуулах", "show_week_numbers_desc": "Ми-календарт долоо хоногийн тоог харуулах", + "calendar_free_scroll": "Чөлөөт гүйлгэлт", + "calendar_free_scroll_desc": "Нэг үеийг харуулахын оронд сар, долоо хоног, өдрүүдийг тасралтгүй гүйлгэх", "enable_tasks": "Даалгавруудыг идэвхжүүлэх", "enable_tasks_desc": "Хийх ажлуудыг удирдахын тулд календарьт ажлын харагдах байдлыг харуулах", "show_tasks_on_calendar": "Хуанли дээр даалгавруудыг харуулах", diff --git a/locales/nb/common.json b/locales/nb/common.json index 64e6c89d..e37def47 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Dupliser", "today_header": "I dag", "tomorrow_header": "I morgen", + "agenda_show_earlier": "Vis tidligere hendelser", + "agenda_loading": "Laster…", + "agenda_range_start": "Hendelser fra {date}", + "agenda_range_end": "Hendelser til {date}", "export_ics": "Eksporter som .ics", "copy_title": "Kopier tittel", "copy_link": "Kopier møtelenken", @@ -2931,6 +2935,8 @@ "show_time_in_month_view_desc": "Vis klokkeslettet for hendelser i månedsvisningen. På små skjermer vises hele hendelsesoppføringen i stedet for en prikk.", "show_week_numbers": "Vis ukenummer", "show_week_numbers_desc": "Vis ukenummer i minikalenderen", + "calendar_free_scroll": "Fri rulling", + "calendar_free_scroll_desc": "Rull fortløpende gjennom måneder, uker og dager i stedet for én periode om gangen", "enable_tasks": "Aktiver oppgaver", "enable_tasks_desc": "Vis en oppgavevisning i kalenderen for å administrere gjøremål", "show_tasks_on_calendar": "Vis oppgaver i kalenderen", diff --git a/locales/nl/common.json b/locales/nl/common.json index db2b111c..f27bbb48 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Dupliceren", "today_header": "Vandaag", "tomorrow_header": "Morgen", + "agenda_show_earlier": "Eerdere afspraken tonen", + "agenda_loading": "Laden…", + "agenda_range_start": "Afspraken vanaf {date}", + "agenda_range_end": "Afspraken tot {date}", "export_ics": "Exporteren als .ics", "copy_title": "Titel kopiëren", "copy_link": "Vergaderlink kopiëren", @@ -2931,6 +2935,8 @@ "show_time_in_month_view_desc": "Evenementtijden weergeven in de maandkalenderweergave", "show_week_numbers": "Weeknummers weergeven", "show_week_numbers_desc": "Weeknummers weergeven in de minikalender", + "calendar_free_scroll": "Vrij scrollen", + "calendar_free_scroll_desc": "Doorlopend door maanden, weken en dagen scrollen in plaats van één periode tegelijk", "enable_tasks": "Taken inschakelen", "enable_tasks_desc": "Een takenweergave in de agenda tonen om to-do's te beheren", "show_tasks_on_calendar": "Taken in agenda tonen", diff --git a/locales/pl/common.json b/locales/pl/common.json index b08be9a1..69b0cb99 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Duplikuj", "today_header": "Dzisiaj", "tomorrow_header": "Jutro", + "agenda_show_earlier": "Pokaż wcześniejsze wydarzenia", + "agenda_loading": "Ładowanie…", + "agenda_range_start": "Wydarzenia od {date}", + "agenda_range_end": "Wydarzenia do {date}", "export_ics": "Eksportuj jako .ics", "copy_title": "Kopiuj tytuł", "copy_link": "Kopiuj link do spotkania", @@ -2931,6 +2935,8 @@ "show_time_in_month_view_desc": "Wyświetlaj godziny wydarzeń w miesięcznym widoku kalendarza", "show_week_numbers": "Pokaż numery tygodni", "show_week_numbers_desc": "Wyświetlaj numery tygodni w mini-kalendarzu", + "calendar_free_scroll": "Swobodne przewijanie", + "calendar_free_scroll_desc": "Przewijaj płynnie miesiące, tygodnie i dni zamiast jednego okresu naraz", "enable_tasks": "Włącz zadania", "enable_tasks_desc": "Pokaż widok zadań w kalendarzu do zarządzania zadaniami", "show_tasks_on_calendar": "Pokaż zadania w kalendarzu", diff --git a/locales/pt/common.json b/locales/pt/common.json index 3166c1b6..d1970d94 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Duplicar", "today_header": "Hoje", "tomorrow_header": "Amanhã", + "agenda_show_earlier": "Mostrar eventos anteriores", + "agenda_loading": "A carregar…", + "agenda_range_start": "Eventos a partir de {date}", + "agenda_range_end": "Eventos até {date}", "export_ics": "Exportar como .ics", "copy_title": "Copiar título", "copy_link": "Copiar link da reunião", @@ -2931,6 +2935,8 @@ "show_time_in_month_view_desc": "Exibir horários dos eventos na visualização mensal do calendário", "show_week_numbers": "Mostrar números da semana", "show_week_numbers_desc": "Exibir números da semana no minicalendário", + "calendar_free_scroll": "Deslocamento livre", + "calendar_free_scroll_desc": "Percorrer meses, semanas e dias de forma contínua em vez de um período de cada vez", "enable_tasks": "Ativar tarefas", "enable_tasks_desc": "Mostrar uma visualização de tarefas no calendário para gerenciar pendências", "show_tasks_on_calendar": "Mostrar tarefas no calendário", diff --git a/locales/ro/common.json b/locales/ro/common.json index c9c5bf3f..f5f9dd53 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Duplicare", "today_header": "Astăzi", "tomorrow_header": "Mâine", + "agenda_show_earlier": "Afișează evenimentele anterioare", + "agenda_loading": "Se încarcă…", + "agenda_range_start": "Evenimente de la {date}", + "agenda_range_end": "Evenimente până la {date}", "export_ics": "Exportați ca fișier .ics", "copy_title": "Copiați titlul", "copy_link": "Copiați linkul întâlnirii", @@ -2931,6 +2935,8 @@ "show_time_in_month_view_desc": "Afișați orele evenimentelor în vizualizarea calendarului lunar", "show_week_numbers": "Afișați numerele săptămânilor", "show_week_numbers_desc": "Afișați numerele săptămânilor în mini-calendar", + "calendar_free_scroll": "Derulare liberă", + "calendar_free_scroll_desc": "Derulează continuu prin luni, săptămâni și zile în loc de o singură perioadă odată", "enable_tasks": "Activați sarcinile", "enable_tasks_desc": "Afișați o vizualizare a sarcinilor în calendar pentru gestionarea sarcinilor de îndeplinit", "show_tasks_on_calendar": "Afișați sarcinile în calendar", diff --git a/locales/ru/common.json b/locales/ru/common.json index 609981d7..3a51f6e3 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Дублировать", "today_header": "Сегодня", "tomorrow_header": "Завтра", + "agenda_show_earlier": "Показать более ранние события", + "agenda_loading": "Загрузка…", + "agenda_range_start": "События с {date}", + "agenda_range_end": "События до {date}", "export_ics": "Экспорт в .ics", "copy_title": "Скопировать название", "copy_link": "Скопировать ссылку встречи", @@ -2931,6 +2935,8 @@ "show_time_in_month_view_desc": "Отображать время события в виде месячного календаря", "show_week_numbers": "Показывать номера недель", "show_week_numbers_desc": "Отображать номера недель в мини-календаре", + "calendar_free_scroll": "Свободная прокрутка", + "calendar_free_scroll_desc": "Непрерывно прокручивать месяцы, недели и дни вместо одного периода за раз", "enable_tasks": "Включить задачи", "enable_tasks_desc": "Показывать представление задач в календаре для управления делами", "show_tasks_on_calendar": "Показывать задачи в календаре", diff --git a/locales/sk/common.json b/locales/sk/common.json index 8125ec96..38a9537e 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Duplikovať", "today_header": "Dnes", "tomorrow_header": "Zajtra", + "agenda_show_earlier": "Zobraziť skoršie udalosti", + "agenda_loading": "Načítava sa…", + "agenda_range_start": "Udalosti od {date}", + "agenda_range_end": "Udalosti do {date}", "export_ics": "Exportovať ako .ics", "copy_title": "Kopírovať názov", "copy_link": "Kopírovať odkaz na stretnutie", @@ -2931,6 +2935,8 @@ "show_time_in_month_view_desc": "Zobrazovať časy udalostí v mesačnom zobrazení kalendára", "show_week_numbers": "Zobraziť čísla týždňov", "show_week_numbers_desc": "Zobrazovať čísla týždňov v minikalendári", + "calendar_free_scroll": "Voľné posúvanie", + "calendar_free_scroll_desc": "Plynulo prechádzať mesiace, týždne a dni namiesto jedného obdobia naraz", "enable_tasks": "Povoliť úlohy", "enable_tasks_desc": "Zobrazovať zobrazenie úloh v kalendári", "show_tasks_on_calendar": "Zobraziť úlohy v kalendári", diff --git a/locales/tr/common.json b/locales/tr/common.json index f98eb8c5..8df0a36a 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -2776,6 +2776,10 @@ "duplicate": "Çoğalt", "today_header": "Bugün", "tomorrow_header": "Yarın", + "agenda_show_earlier": "Önceki etkinlikleri göster", + "agenda_loading": "Yükleniyor…", + "agenda_range_start": "{date} tarihinden itibaren etkinlikler", + "agenda_range_end": "{date} tarihine kadar etkinlikler", "export_ics": ".ics olarak dışa aktar", "copy_title": "Başlığı kopyala", "copy_link": "Toplantı bağlantısını kopyala", @@ -2931,6 +2935,8 @@ "show_time_in_month_view_desc": "Aylık takvim görünümünde etkinlik saatlerini görüntüle", "show_week_numbers": "Hafta numaralarını göster", "show_week_numbers_desc": "Mini takvimde hafta numaralarını görüntüle", + "calendar_free_scroll": "Serbest kaydırma", + "calendar_free_scroll_desc": "Tek seferde bir dönem yerine aylar, haftalar ve günler arasında kesintisiz kaydırın", "enable_tasks": "Görevleri etkinleştir", "enable_tasks_desc": "Yapılacaklar listesini yönetmek için takvimde görev görünümü göster", "show_tasks_on_calendar": "Görevleri takvimde göster", diff --git a/locales/uk/common.json b/locales/uk/common.json index 2b3ddbb3..1910a3c7 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -2776,6 +2776,10 @@ "duplicate": "дублікат", "today_header": "Сьогодні", "tomorrow_header": "завтра", + "agenda_show_earlier": "Показати попередні події", + "agenda_loading": "Завантаження…", + "agenda_range_start": "Події з {date}", + "agenda_range_end": "Події до {date}", "export_ics": "Експортувати як .ics", "copy_title": "Скопіювати назву", "copy_link": "Скопіювати посилання зустрічі", @@ -2931,6 +2935,8 @@ "show_time_in_month_view_desc": "Відображення часу подій у місячному календарі", "show_week_numbers": "Показати номери тижнів", "show_week_numbers_desc": "Відображення номерів тижнів у міні-календарі", + "calendar_free_scroll": "Вільне прокручування", + "calendar_free_scroll_desc": "Безперервно прокручувати місяці, тижні та дні замість одного періоду за раз", "enable_tasks": "Увімкнути завдання", "enable_tasks_desc": "Показати перегляд завдань у календарі для керування справами", "show_tasks_on_calendar": "Показати завдання в календарі", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index b3e1b3d4..b2bdfc86 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -2776,6 +2776,10 @@ "duplicate": "建立副本", "today_header": "今天", "tomorrow_header": "明天", + "agenda_show_earlier": "顯示更早的行程", + "agenda_loading": "載入中…", + "agenda_range_start": "顯示 {date} 起的行程", + "agenda_range_end": "顯示至 {date} 的行程", "export_ics": "匯出為 .ics", "copy_title": "複製標題", "copy_link": "複製會議連結", @@ -2931,6 +2935,8 @@ "show_time_in_month_view_desc": "在月檢視中顯示活動時間。小螢幕會顯示完整活動項目,而不只是圓點。", "show_week_numbers": "顯示週數", "show_week_numbers_desc": "在迷你行事曆中顯示週數", + "calendar_free_scroll": "自由捲動", + "calendar_free_scroll_desc": "連續捲動瀏覽月、週和日,而不是一次只顯示一個時段", "enable_tasks": "啟用任務", "enable_tasks_desc": "在行事曆中顯示任務檢視以管理待辦事項", "show_tasks_on_calendar": "在行事曆上顯示任務", diff --git a/locales/zh/common.json b/locales/zh/common.json index bdffc346..1eeba3c9 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -2776,6 +2776,10 @@ "duplicate": "复制", "today_header": "今天", "tomorrow_header": "明天", + "agenda_show_earlier": "显示更早的日程", + "agenda_loading": "加载中…", + "agenda_range_start": "显示 {date} 起的日程", + "agenda_range_end": "显示至 {date} 的日程", "export_ics": "导出为 .ics", "copy_title": "复制标题", "copy_link": "复制会议链接", @@ -2931,6 +2935,8 @@ "show_time_in_month_view_desc": "在月历视图中显示事件时间", "show_week_numbers": "显示周数", "show_week_numbers_desc": "在迷你日历中显示周数", + "calendar_free_scroll": "自由滚动", + "calendar_free_scroll_desc": "连续滚动浏览月、周和日,而不是一次只显示一个时段", "enable_tasks": "启用任务", "enable_tasks_desc": "在日历中显示任务视图以管理待办事项", "show_tasks_on_calendar": "在日历上显示任务", diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 39a767ce..fb584065 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -367,6 +367,8 @@ interface SettingsState { // Calendar showTimeInMonthView: boolean; showWeekNumbers: boolean; + /** Scroll continuously through months/weeks/days (#759) instead of one period at a time. */ + calendarFreeScroll: boolean; calendarHoverPreview: CalendarHoverPreview; // Calendar Tasks @@ -600,6 +602,7 @@ const DEFAULT_SETTINGS = { // Calendar showTimeInMonthView: false, showWeekNumbers: false, + calendarFreeScroll: true, calendarHoverPreview: 'delay-500ms' as CalendarHoverPreview, // Calendar Tasks @@ -831,6 +834,7 @@ export const useSettingsStore = create()( expandedFilterView: state.expandedFilterView, showTimeInMonthView: state.showTimeInMonthView, showWeekNumbers: state.showWeekNumbers, + calendarFreeScroll: state.calendarFreeScroll, calendarHoverPreview: state.calendarHoverPreview, toolbarPosition: state.toolbarPosition, hideAccountSwitcher: state.hideAccountSwitcher,