refactor timeentries queries and mutations, improve activitygraph, add dashboard reporting table

This commit is contained in:
Gregor Vostrak
2026-01-14 17:01:45 +01:00
parent 0a6bde8bc6
commit 81d9561656
31 changed files with 723 additions and 437 deletions

View File

@@ -1,9 +1,7 @@
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
export function initializeStores() {
// TanStack Query now handles projects, tasks, tags, clients, and members fetching automatically
// Only initialize stores that aren't migrated to TanStack Query yet
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
useTimeEntriesStore().patchTimeEntries();
}

View File

@@ -1,6 +1,6 @@
import type { QueryClient } from '@tanstack/vue-query';
import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
import { canViewClients, canViewMembers } from '@/utils/permissions';
/**
@@ -21,6 +21,7 @@ const routePrefetchers: Record<string, (queryClient: QueryClient) => void> = {
prefetchTasks(queryClient);
prefetchTags(queryClient);
prefetchClients(queryClient);
prefetchTimeEntries(queryClient);
},
'/calendar': (queryClient) => {
@@ -221,6 +222,28 @@ function prefetchReports(queryClient: QueryClient) {
});
}
function prefetchTimeEntries(queryClient: QueryClient) {
const organizationId = getCurrentOrganizationId();
const memberId = getCurrentMembershipId();
if (!organizationId) return;
queryClient.prefetchInfiniteQuery({
queryKey: ['timeEntries', 'infinite', { organizationId, memberId }],
queryFn: async () => {
const response = await api.getTimeEntries({
params: { organization: organizationId },
queries: {
only_full_dates: 'true',
member_id: memberId,
},
});
return response;
},
initialPageParam: undefined,
staleTime: 30000,
});
}
function prefetchProjectMembers(queryClient: QueryClient, projectId: string) {
const organizationId = getCurrentOrganizationId();
if (!organizationId || !canViewMembers()) return;

View File

@@ -10,8 +10,8 @@ import {
getCurrentUserId,
} from '@/utils/useUser';
import { useLocalStorage } from '@vueuse/core';
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
import { useNotificationsStore } from '@/utils/notification';
import { useQueryClient } from '@tanstack/vue-query';
dayjs.extend(utc);
@@ -32,6 +32,7 @@ const emptyTimeEntry = {
export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
const currentTimeEntry = ref<TimeEntry>(reactive(emptyTimeEntry));
const { handleApiRequestNotifications } = useNotificationsStore();
const queryClient = useQueryClient();
useLocalStorage('solidtime/current-time-entry', currentTimeEntry, {
deep: true,
@@ -208,7 +209,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
stopLiveTimer();
await stopTimer();
}
useTimeEntriesStore().fetchTimeEntries();
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
}
return {

View File

@@ -1,246 +0,0 @@
import { defineStore } from 'pinia';
import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
import { reactive, ref, type Ref } from 'vue';
import { api } from '@/packages/api/src';
import type {
CreateTimeEntryBody,
TimeEntriesQueryParams,
TimeEntry,
UpdateMultipleTimeEntriesChangeset,
} from '@/packages/api/src';
import dayjs from 'dayjs';
import { useNotificationsStore } from '@/utils/notification';
import type {} from '@/packages/api/src';
import { useQueryClient } from '@tanstack/vue-query';
export const useTimeEntriesStore = defineStore(
'timeEntries',
(): {
timeEntries: Ref<TimeEntry[]>;
fetchTimeEntries: (queryParams?: TimeEntriesQueryParams) => Promise<void>;
updateTimeEntry: (timeEntry: TimeEntry) => Promise<void>;
createTimeEntry: (timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) => Promise<void>;
deleteTimeEntry: (timeEntryId: string) => Promise<void>;
fetchMoreTimeEntries: () => Promise<void>;
allTimeEntriesLoaded: Ref<boolean>;
updateTimeEntries: (
ids: string[],
changes: UpdateMultipleTimeEntriesChangeset
) => Promise<void>;
deleteTimeEntries: (timeEntries: TimeEntry[]) => Promise<void>;
patchTimeEntries: (queryParams?: TimeEntriesQueryParams) => Promise<void>;
} => {
const timeEntries = ref<TimeEntry[]>(reactive([]));
const allTimeEntriesLoaded = ref(false);
const { handleApiRequestNotifications } = useNotificationsStore();
const queryClient = useQueryClient();
async function patchTimeEntries(
queryParams: TimeEntriesQueryParams = {
only_full_dates: 'true',
member_id: getCurrentMembershipId(),
}
) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const timeEntriesResponse = await handleApiRequestNotifications(
() =>
api.getTimeEntries({
params: {
organization: organizationId,
},
queries: queryParams,
}),
undefined,
'Failed to fetch time entries'
);
if (timeEntriesResponse?.data) {
// insert missing time entries
const missingTimeEntries = timeEntriesResponse.data.filter(
(entry) => !timeEntries.value.find((e) => e.id === entry.id)
);
timeEntries.value = [...missingTimeEntries, ...timeEntries.value];
}
}
}
async function fetchTimeEntries(
queryParams: TimeEntriesQueryParams = {
only_full_dates: 'true',
member_id: getCurrentMembershipId(),
}
) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const timeEntriesResponse = await handleApiRequestNotifications(
() =>
api.getTimeEntries({
params: {
organization: organizationId,
},
queries: queryParams,
}),
undefined,
'Failed to fetch time entries'
);
if (timeEntriesResponse?.data) {
timeEntries.value = timeEntriesResponse.data;
}
}
}
async function fetchMoreTimeEntries() {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const latestTimeEntry = timeEntries.value[timeEntries.value.length - 1];
dayjs(latestTimeEntry.start).utc().format('YYYY-MM-DD');
const timeEntriesResponse = await handleApiRequestNotifications(
() =>
api.getTimeEntries({
params: {
organization: organizationId,
},
queries: {
only_full_dates: 'true',
member_id: getCurrentMembershipId(),
end: dayjs(latestTimeEntry.start).utc().format(),
},
}),
undefined,
'Failed to fetch time entries'
);
if (timeEntriesResponse?.data && timeEntriesResponse.data.length > 0) {
timeEntries.value = timeEntries.value.concat(timeEntriesResponse.data);
} else {
allTimeEntriesLoaded.value = true;
}
}
}
async function updateTimeEntries(
ids: string[],
changes: UpdateMultipleTimeEntriesChangeset
) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
await handleApiRequestNotifications(
() =>
api.updateMultipleTimeEntries(
{
ids: ids,
changes: changes,
},
{
params: {
organization: organizationId,
},
}
),
'Time entries updated successfully',
'Failed to update time entries'
);
}
}
async function updateTimeEntry(timeEntry: TimeEntry) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
const response = await handleApiRequestNotifications(
() =>
api.updateTimeEntry(timeEntry, {
params: {
organization: organizationId,
timeEntry: timeEntry.id,
},
}),
'Time entry updated successfully',
'Failed to update time entry'
);
timeEntries.value = timeEntries.value.map((entry) =>
entry.id === timeEntry.id ? response.data : entry
);
queryClient.invalidateQueries({ queryKey: ['timeEntry'] });
}
}
async function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) {
const organizationId = getCurrentOrganizationId();
const memberId = getCurrentMembershipId();
if (organizationId && memberId !== undefined) {
const newTimeEntry = {
...timeEntry,
member_id: memberId,
} as CreateTimeEntryBody;
await handleApiRequestNotifications(
() =>
api.createTimeEntry(newTimeEntry, {
params: {
organization: organizationId,
},
}),
'Time entry created successfully',
'Failed to create time entry'
);
await fetchTimeEntries();
}
}
async function deleteTimeEntry(timeEntryId: string) {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
await handleApiRequestNotifications(
() =>
api.deleteTimeEntry(undefined, {
params: {
organization: organizationId,
timeEntry: timeEntryId,
},
}),
'Time entry deleted successfully',
'Failed to delete time entry'
);
await fetchTimeEntries();
}
}
async function deleteTimeEntries(timeEntries: TimeEntry[]) {
const organizationId = getCurrentOrganizationId();
const timeEntryIds = timeEntries.map((entry) => entry.id);
if (organizationId) {
await handleApiRequestNotifications(
() =>
api.deleteTimeEntries(undefined, {
queries: {
ids: timeEntryIds,
},
params: {
organization: organizationId,
},
}),
'Time entries deleted successfully',
'Failed to delete time entries'
);
await fetchTimeEntries();
}
}
return {
timeEntries,
fetchTimeEntries,
updateTimeEntry,
createTimeEntry,
deleteTimeEntry,
fetchMoreTimeEntries,
allTimeEntriesLoaded,
updateTimeEntries,
deleteTimeEntries,
patchTimeEntries,
};
}
);

View File

@@ -0,0 +1,65 @@
import { useQuery } from '@tanstack/vue-query';
import { api, type TimeEntryResponse } 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';
export function useTimeEntriesCalendarQuery(
calendarStart: Ref<Date | undefined>,
calendarEnd: Ref<Date | undefined>
) {
const enableCalendarQuery = computed(() => {
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 useQuery<TimeEntryResponse>({
queryKey: computed(() => [
'timeEntries',
'calendar',
{
start: expandedDateRange.value.start,
end: expandedDateRange.value.end,
organization: getCurrentOrganizationId(),
},
]),
enabled: enableCalendarQuery,
placeholderData: (previousData) => previousData,
queryFn: () =>
api.getTimeEntries({
params: {
organization: getCurrentOrganizationId() || '',
},
queries: {
start: expandedDateRange.value.start!,
end: expandedDateRange.value.end!,
member_id: getCurrentMembershipId(),
},
}),
staleTime: 1000 * 30, // 30 seconds
});
}

View File

@@ -0,0 +1,51 @@
import { useInfiniteQuery } from '@tanstack/vue-query';
import { api } from '@/packages/api/src';
import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
import dayjs from 'dayjs';
import { computed } from 'vue';
export function useTimeEntriesInfiniteQuery() {
const organizationId = computed(() => getCurrentOrganizationId());
const memberId = computed(() => getCurrentMembershipId());
return useInfiniteQuery({
queryKey: computed(() => [
'timeEntries',
'infinite',
{ organizationId: organizationId.value, memberId: memberId.value },
]),
queryFn: async ({ pageParam }) => {
const orgId = organizationId.value;
if (!orgId) return { data: [] };
const queries: Record<string, string | undefined> = {
only_full_dates: 'true',
member_id: memberId.value,
};
if (pageParam) {
queries.end = pageParam;
}
const response = await api.getTimeEntries({
params: {
organization: orgId,
},
queries: queries,
});
return response;
},
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => {
if (!lastPage?.data || lastPage.data.length === 0) {
return undefined;
}
const latestTimeEntry = lastPage.data[lastPage.data.length - 1];
return dayjs(latestTimeEntry.start).utc().format();
},
enabled: computed(() => !!organizationId.value),
staleTime: 1000 * 30, // 30 seconds
gcTime: 1000 * 60 * 10, // 10 minutes
});
}

View File

@@ -0,0 +1,151 @@
import { useMutation, useQueryClient } from '@tanstack/vue-query';
import {
api,
type CreateTimeEntryBody,
type TimeEntry,
type UpdateMultipleTimeEntriesChangeset,
} from '@/packages/api/src';
import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
export function useTimeEntriesMutations() {
const queryClient = useQueryClient();
const { handleApiRequestNotifications } = useNotificationsStore();
const { mutateAsync: createTimeEntry } = useMutation({
mutationFn: async (timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) => {
const organizationId = getCurrentOrganizationId();
const memberId = getCurrentMembershipId();
if (organizationId && memberId !== undefined) {
const newTimeEntry = {
...timeEntry,
member_id: memberId,
} as CreateTimeEntryBody;
return await handleApiRequestNotifications(
() =>
api.createTimeEntry(newTimeEntry, {
params: {
organization: organizationId,
},
}),
'Time entry created successfully',
'Failed to create time entry'
);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
},
});
const { mutateAsync: updateTimeEntry } = useMutation({
mutationFn: async (timeEntry: TimeEntry) => {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
return await handleApiRequestNotifications(
() =>
api.updateTimeEntry(timeEntry, {
params: {
organization: organizationId,
timeEntry: timeEntry.id,
},
}),
'Time entry updated successfully',
'Failed to update time entry'
);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
},
});
const { mutateAsync: updateTimeEntries } = useMutation({
mutationFn: async ({
ids,
changes,
}: {
ids: string[];
changes: UpdateMultipleTimeEntriesChangeset;
}) => {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
return await handleApiRequestNotifications(
() =>
api.updateMultipleTimeEntries(
{
ids: ids,
changes: changes,
},
{
params: {
organization: organizationId,
},
}
),
'Time entries updated successfully',
'Failed to update time entries'
);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
},
});
const { mutateAsync: deleteTimeEntry } = useMutation({
mutationFn: async (timeEntryId: string) => {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
return await handleApiRequestNotifications(
() =>
api.deleteTimeEntry(undefined, {
params: {
organization: organizationId,
timeEntry: timeEntryId,
},
}),
'Time entry deleted successfully',
'Failed to delete time entry'
);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
},
});
const { mutateAsync: deleteTimeEntries } = useMutation({
mutationFn: async (timeEntries: TimeEntry[]) => {
const organizationId = getCurrentOrganizationId();
const timeEntryIds = timeEntries.map((entry) => entry.id);
if (organizationId) {
return await handleApiRequestNotifications(
() =>
api.deleteTimeEntries(undefined, {
queries: {
ids: timeEntryIds,
},
params: {
organization: organizationId,
},
}),
'Time entries deleted successfully',
'Failed to delete time entries'
);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
},
});
return {
createTimeEntry,
updateTimeEntry,
updateTimeEntries,
deleteTimeEntry,
deleteTimeEntries,
};
}

View File

@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/vue-query';
import { api, type TimeEntryResponse } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { computed, type Ref, type ComputedRef, unref } from 'vue';
export function useTimeEntriesReportQuery(
filterParams: Ref<Record<string, unknown>> | ComputedRef<Record<string, unknown>>
) {
return useQuery<TimeEntryResponse>({
queryKey: computed(() => ['timeEntries', 'detailed-report', unref(filterParams)]),
enabled: computed(() => !!getCurrentOrganizationId()),
queryFn: () =>
api.getTimeEntries({
params: {
organization: getCurrentOrganizationId() || '',
},
queries: { ...unref(filterParams) },
}),
staleTime: 1000 * 30, // 30 seconds
});
}