diff --git a/e2e/goals.spec.ts b/e2e/goals.spec.ts index 0a852e43..16986276 100644 --- a/e2e/goals.spec.ts +++ b/e2e/goals.spec.ts @@ -342,6 +342,89 @@ test('test that a running time entry counts towards the goal progress', async ({ ); }); +// Close to midnight UTC an entry that started a few minutes ago could fall on the previous day, so +// the day of the goal is moved to a timezone (without daylight saving time) where it is late morning +function timezoneAwayFromMidnight() { + const utcHour = new Date().getUTCHours(); + return utcHour >= 2 && utcHour < 22 ? 'UTC' : 'Asia/Tokyo'; +} + +// Navigates via the sidebar, a full page load would start with an empty query cache and hide +// a missing goals invalidation +async function navigateViaSidebar(page: Page, name: 'Time' | 'Goals') { + await page.getByRole('link', { name, exact: true }).first().click(); + await expect(page).toHaveURL(new RegExp('/' + name.toLowerCase() + '$')); +} + +test('test that goal progress updates after creating a time entry without a page reload', async ({ + page, + ctx, +}) => { + const goal = await createGoalViaApi(ctx, { + name: 'Cache goal', + target_seconds: 3600, + period: 'day', + timezone: timezoneAwayFromMidnight(), + }); + + await goToGoalsOverview(page); + await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText('0h 00min'); + + await navigateViaSidebar(page, 'Time'); + await page.getByRole('button', { name: 'Time entry actions' }).click(); + await page.getByRole('menuitem', { name: 'Manual time entry' }).click(); + const durationInput = page.locator('[role="dialog"] input[name="Duration"]'); + await durationInput.fill('15min'); + await durationInput.press('Tab'); + await Promise.all([ + page.waitForResponse( + (response) => response.url().includes('/time-entries') && response.status() === 201 + ), + page.getByRole('button', { name: 'Create Time Entry' }).click(), + ]); + + await navigateViaSidebar(page, 'Goals'); + await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText('0h 15min'); +}); + +test('test that goal progress updates after changing the project of the running time entry without a page reload', async ({ + page, + ctx, +}) => { + const project = await createProjectViaApi(ctx, { name: 'Running Project', is_public: true }); + const start = new Date(Date.now() - 30 * 60 * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z'); + await createRunningTimeEntryWithStartViaApi(ctx, 'Running work', start); + const goal = await createGoalViaApi(ctx, { + name: 'Running project goal', + target_seconds: 3600, + period: 'day', + timezone: timezoneAwayFromMidnight(), + filters: { project_ids: [project.id], time_entry_type: 'work' }, + }); + + await goToGoalsOverview(page); + await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText('0h 00min'); + + await navigateViaSidebar(page, 'Time'); + await page.getByRole('button', { name: 'No Project' }).click(); + await Promise.all([ + page.waitForResponse( + async (response) => + response.url().includes('/time-entries') && + response.request().method() === 'PUT' && + response.status() === 200 && + (await response.json()).data.project_id === project.id + ), + page.getByRole('option').filter({ hasText: project.name }).click(), + ]); + + await navigateViaSidebar(page, 'Goals'); + // The entry keeps running while the test runs + await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText( + /^0h 3[0-2]min$/ + ); +}); + test('test that editing a goal via the modal works', async ({ page, ctx }) => { const goal = await createGoalViaApi(ctx, { name: 'Old goal name', diff --git a/resources/js/Components/Common/Member/MemberDeleteModal.vue b/resources/js/Components/Common/Member/MemberDeleteModal.vue index ccc36469..9ecd7ef7 100644 --- a/resources/js/Components/Common/Member/MemberDeleteModal.vue +++ b/resources/js/Components/Common/Member/MemberDeleteModal.vue @@ -43,6 +43,7 @@ const deleteMutation = useMutation({ onSuccess: () => { close(); queryClient.invalidateQueries({ queryKey: ['members'] }); + queryClient.invalidateQueries({ queryKey: ['goals'] }); }, }); diff --git a/resources/js/Components/Common/Member/MemberMergeModal.vue b/resources/js/Components/Common/Member/MemberMergeModal.vue index 69441029..b1eefe91 100644 --- a/resources/js/Components/Common/Member/MemberMergeModal.vue +++ b/resources/js/Components/Common/Member/MemberMergeModal.vue @@ -52,6 +52,7 @@ async function submit() { 'There was an error merging the members.', () => { queryClient.invalidateQueries({ queryKey: ['members'] }); + queryClient.invalidateQueries({ queryKey: ['goals'] }); show.value = false; } ); diff --git a/resources/js/utils/timesheet/useBreakPlacement.ts b/resources/js/utils/timesheet/useBreakPlacement.ts index ff10e30b..eecd3423 100644 --- a/resources/js/utils/timesheet/useBreakPlacement.ts +++ b/resources/js/utils/timesheet/useBreakPlacement.ts @@ -422,6 +422,7 @@ export function useBreakPlacement(deps: BreakPlacementDeps) { }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); + queryClient.invalidateQueries({ queryKey: ['goals'] }); }, }); diff --git a/resources/js/utils/timesheet/useCopyLastWeek.ts b/resources/js/utils/timesheet/useCopyLastWeek.ts index 2769173a..859a16f3 100644 --- a/resources/js/utils/timesheet/useCopyLastWeek.ts +++ b/resources/js/utils/timesheet/useCopyLastWeek.ts @@ -217,6 +217,7 @@ export function useCopyLastWeek( } queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); + queryClient.invalidateQueries({ queryKey: ['goals'] }); if (attempted === 0) return; diff --git a/resources/js/utils/timesheet/useTimesheetCellMutations.ts b/resources/js/utils/timesheet/useTimesheetCellMutations.ts index edc4fa84..3b019899 100644 --- a/resources/js/utils/timesheet/useTimesheetCellMutations.ts +++ b/resources/js/utils/timesheet/useTimesheetCellMutations.ts @@ -174,6 +174,7 @@ export function useTimesheetCellMutations( ); } finally { queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); + queryClient.invalidateQueries({ queryKey: ['goals'] }); } } diff --git a/resources/js/utils/useCurrentTimeEntry.ts b/resources/js/utils/useCurrentTimeEntry.ts index a0584a1f..d889aa75 100644 --- a/resources/js/utils/useCurrentTimeEntry.ts +++ b/resources/js/utils/useCurrentTimeEntry.ts @@ -261,6 +261,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => { stopLiveTimer(); } } + queryClient.invalidateQueries({ queryKey: ['goals'] }); } else { throw new Error( 'Failed to fetch current time entry because organization ID is missing.' diff --git a/resources/js/utils/useTimeEntriesMutations.ts b/resources/js/utils/useTimeEntriesMutations.ts index 3b45b8c5..64ab3ae2 100644 --- a/resources/js/utils/useTimeEntriesMutations.ts +++ b/resources/js/utils/useTimeEntriesMutations.ts @@ -36,6 +36,7 @@ export function useTimeEntriesMutations() { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); + queryClient.invalidateQueries({ queryKey: ['goals'] }); }, }); @@ -58,6 +59,7 @@ export function useTimeEntriesMutations() { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); + queryClient.invalidateQueries({ queryKey: ['goals'] }); }, }); @@ -105,6 +107,7 @@ export function useTimeEntriesMutations() { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); + queryClient.invalidateQueries({ queryKey: ['goals'] }); }, }); @@ -127,6 +130,7 @@ export function useTimeEntriesMutations() { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); + queryClient.invalidateQueries({ queryKey: ['goals'] }); }, }); @@ -152,6 +156,7 @@ export function useTimeEntriesMutations() { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); + queryClient.invalidateQueries({ queryKey: ['goals'] }); }, });