From a58becc2682a1988e690709e7019cd0c872da65e Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Tue, 3 Feb 2026 14:12:36 +0100 Subject: [PATCH] Add calendar query prefetch --- .../Common/Reporting/ReportingRow.vue | 2 +- resources/js/utils/prefetch.ts | 23 +++ resources/js/utils/useOrganizationQuery.ts | 1 + .../js/utils/useTimeEntriesCalendarQuery.ts | 177 ++++++++++++------ 4 files changed, 149 insertions(+), 54 deletions(-) diff --git a/resources/js/Components/Common/Reporting/ReportingRow.vue b/resources/js/Components/Common/Reporting/ReportingRow.vue index 031be28f..450daf8d 100644 --- a/resources/js/Components/Common/Reporting/ReportingRow.vue +++ b/resources/js/Components/Common/Reporting/ReportingRow.vue @@ -66,7 +66,7 @@ const organization = inject>('organization');
void> = { prefetchTasks(queryClient); prefetchTags(queryClient); prefetchClients(queryClient); + prefetchCalendarTimeEntries(queryClient); }, '/projects': (queryClient) => { @@ -244,6 +251,22 @@ function prefetchTimeEntries(queryClient: QueryClient) { }); } +function prefetchCalendarTimeEntries(queryClient: QueryClient) { + const organizationId = getCurrentOrganizationId(); + const memberId = getCurrentMembershipId(); + if (!organizationId) return; + + const { start, end } = getInitialWeekRange(); + const { start: formattedStart, end: formattedEnd } = getExpandedCalendarDateRange(start, end); + + queryClient.prefetchQuery({ + queryKey: createCalendarQueryKey(formattedStart, formattedEnd, organizationId), + queryFn: () => + fetchAllCalendarEntries(organizationId, memberId, formattedStart, formattedEnd), + staleTime: 30000, + }); +} + function prefetchProjectMembers(queryClient: QueryClient, projectId: string) { const organizationId = getCurrentOrganizationId(); if (!organizationId || !canViewMembers()) return; diff --git a/resources/js/utils/useOrganizationQuery.ts b/resources/js/utils/useOrganizationQuery.ts index bee5a693..aaad9d26 100644 --- a/resources/js/utils/useOrganizationQuery.ts +++ b/resources/js/utils/useOrganizationQuery.ts @@ -11,6 +11,7 @@ export function useOrganizationQuery(organizationId: string) { organization: organizationId, }, }), + staleTime: 1000 * 30, }); const organization = computed(() => query.data.value?.data); diff --git a/resources/js/utils/useTimeEntriesCalendarQuery.ts b/resources/js/utils/useTimeEntriesCalendarQuery.ts index 857ca842..215c555e 100644 --- a/resources/js/utils/useTimeEntriesCalendarQuery.ts +++ b/resources/js/utils/useTimeEntriesCalendarQuery.ts @@ -3,7 +3,116 @@ import { api, type TimeEntryResponse, type TimeEntry } from '@/packages/api/src' import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser'; import { computed, type Ref } from 'vue'; import { getDayJsInstance } from '@/packages/ui/src/utils/time'; -import { getUserTimezone } from '@/packages/ui/src/utils/settings'; +import { getUserTimezone, getWeekStart } from '@/packages/ui/src/utils/settings'; + +const weekStartMap: Record = { + sunday: 0, + monday: 1, + tuesday: 2, + wednesday: 3, + thursday: 4, + friday: 5, + saturday: 6, +}; + +/** + * Calculate expanded date range to include previous and next periods with timezone transformations. + * This allows smooth navigation between calendar views without loading delays. + */ +export function getExpandedCalendarDateRange( + calendarStart: Date, + calendarEnd: Date +): { start: string; end: string } { + const dayjs = getDayJsInstance(); + const duration = dayjs(calendarEnd).diff(dayjs(calendarStart), 'milliseconds'); + + // Calculate previous period + const previousStart = dayjs(calendarStart).subtract(duration, 'milliseconds'); + // Calculate next period + const nextEnd = dayjs(calendarEnd).add(duration, 'milliseconds'); + + // Apply timezone transformations + const timezone = getUserTimezone(); + const formattedStart = previousStart.utc().tz(timezone, true).utc().format(); + const formattedEnd = nextEnd.utc().tz(timezone, true).utc().format(); + + return { + start: formattedStart, + end: formattedEnd, + }; +} + +/** + * Get the initial week view date range based on user's week start preference. + * Matches FullCalendar's timeGridWeek initial view. + */ +export function getInitialWeekRange(): { start: Date; end: Date } { + const dayjs = getDayJsInstance(); + const weekStart = getWeekStart(); + const firstDay = weekStartMap[weekStart] ?? 1; + + const now = dayjs(); + const currentDayOfWeek = now.day(); + const daysFromWeekStart = (currentDayOfWeek - firstDay + 7) % 7; + const calendarStart = now.subtract(daysFromWeekStart, 'day').startOf('day'); + const calendarEnd = calendarStart.add(7, 'day'); + + return { + start: calendarStart.toDate(), + end: calendarEnd.toDate(), + }; +} + +/** + * Create the query key for calendar time entries. + */ +export function createCalendarQueryKey( + start: string | null, + end: string | null, + organizationId: string | null +): readonly [ + 'timeEntries', + 'calendar', + { start: string | null; end: string | null; organization: string | null }, +] { + return ['timeEntries', 'calendar', { start, end, organization: organizationId }] as const; +} + +/** + * Fetch all calendar entries with pagination. + */ +export async function fetchAllCalendarEntries( + organizationId: string, + memberId: string | undefined, + start: string, + end: string +): Promise { + const allEntries: TimeEntry[] = []; + + while (true) { + const response = await api.getTimeEntries({ + params: { + organization: organizationId, + }, + queries: { + start, + end, + member_id: memberId, + offset: allEntries.length || undefined, + }, + }); + + if (response.data.length === 0) { + return { data: allEntries, meta: response.meta }; + } + + allEntries.push(...response.data); + + if (allEntries.length >= response.meta.total) { + return { data: allEntries, meta: response.meta }; + } + } +} export function useTimeEntriesCalendarQuery( calendarStart: Ref, @@ -13,68 +122,30 @@ export function useTimeEntriesCalendarQuery( return !!getCurrentOrganizationId() && !!calendarStart.value && !!calendarEnd.value; }); - // Calculate expanded date range to include previous and next periods with timezone transformations const expandedDateRange = computed(() => { if (!calendarStart.value || !calendarEnd.value) { return { start: null, end: null }; } - - const dayjs = getDayJsInstance(); - const duration = dayjs(calendarEnd.value).diff(dayjs(calendarStart.value), 'milliseconds'); - - // Calculate previous period - const previousStart = dayjs(calendarStart.value).subtract(duration, 'milliseconds'); - // Calculate next period - const nextEnd = dayjs(calendarEnd.value).add(duration, 'milliseconds'); - - // Apply timezone transformations - const formattedStart = previousStart.utc().tz(getUserTimezone(), true).utc().format(); - const formattedEnd = nextEnd.utc().tz(getUserTimezone(), true).utc().format(); - - return { - start: formattedStart, - end: formattedEnd, - }; + return getExpandedCalendarDateRange(calendarStart.value, calendarEnd.value); }); return useQuery({ - queryKey: computed(() => [ - 'timeEntries', - 'calendar', - { - start: expandedDateRange.value.start, - end: expandedDateRange.value.end, - organization: getCurrentOrganizationId(), - }, - ]), + queryKey: computed(() => + createCalendarQueryKey( + expandedDateRange.value.start, + expandedDateRange.value.end, + getCurrentOrganizationId() + ) + ), enabled: enableCalendarQuery, placeholderData: (previousData) => previousData, queryFn: async () => { - const allEntries: TimeEntry[] = []; - - while (true) { - const response = await api.getTimeEntries({ - params: { - organization: getCurrentOrganizationId() || '', - }, - queries: { - start: expandedDateRange.value.start!, - end: expandedDateRange.value.end!, - member_id: getCurrentMembershipId(), - offset: allEntries.length || undefined, - }, - }); - - if (response.data.length === 0) { - return { data: allEntries, meta: response.meta }; - } - - allEntries.push(...response.data); - - if (allEntries.length >= response.meta.total) { - return { data: allEntries, meta: response.meta }; - } - } + return fetchAllCalendarEntries( + getCurrentOrganizationId() || '', + getCurrentMembershipId(), + expandedDateRange.value.start!, + expandedDateRange.value.end! + ); }, staleTime: 1000 * 30, // 30 seconds });