Compare commits

..

11 Commits

21 changed files with 49 additions and 251 deletions

View File

@@ -3,7 +3,6 @@ on:
push: push:
branches: branches:
- main - main
workflow_dispatch:
permissions: permissions:
contents: read contents: read
@@ -58,9 +57,10 @@ jobs:
name: api-docs.json name: api-docs.json
path: build/api-docs.json path: build/api-docs.json
- name: "Deploy with FastFront" - name: "Download Fastfront CLI"
uses: fastfront-io/deploy-action@v0.0.1 run: curl https://fastfront-cli.s3.fr-par.scw.cloud/fastfront-cli.phar -o fastfront-cli.phar
with:
folder: './build' - name: "Deploy with Fastfront"
environment: ${{ vars.FASTFRONT_API_DOCS_STAGE }} run: php fastfront-cli.phar deploy 9beab6cf-f459-446b-85f1-38ec007cf457 ./build
api_key: ${{ secrets.FASTFRONT_API_DOCS_STAGE_KEY }} env:
FASTFRONT_API_KEY: ${{ secrets.FASTFRONT_API_DOCS_API_KEY }}

View File

@@ -6,19 +6,7 @@ Contributions are greatly apprecited, please make sure to read the rules and vis
### Issues for Bugs, Discussions for Feature requests ### Issues for Bugs, Discussions for Feature requests
In order to keep the issues of the repository clean we decided to only use them for bugs. Feature Requests and enhancements are handled in discussions. This also helps us to see which feature requests are popular as they can be upvoted. In order to keep the issues of the repository clean we decided to only use them for bugs. Feature Requests and enhancement are handled in discussions. This also helps us to see which feature requests are popular as they can be upvoted.
### Do not use AI-generated text in issues, discussions and PR descriptions
We are human, so please write in natural language in your issues, discussions, and PR descriptions.
Don't be a meatproxy. We will ban people who repeatedly violate this rule from further communication on our repositories.
### Before you contribute, ask yourself what value you add
We much prefer reading your thoughts on features or issues you encountered than reading through hundreds of lines of code changes produced by your agent.
Consider that the maintainers of this project have the same access to LLMs and more knowledge about the project itself.
Prompting a feature into existence without further consideration or discussion is mostly a burden for maintainers.
If we want to, we can burn the tokens ourselves.
### Only work on approved issues ### Only work on approved issues

View File

@@ -30,16 +30,8 @@ If you do not want to self-host solidtime or try it out you can sign up for [sol
## Issues & Feature Requests ## Issues & Feature Requests
If you If you find any **bugs in solidtime**, please feel free to [**open an issue**](https://github.com/solidtime-io/solidtime/issues/new) in this repository, with instructions on how to reproduce the bug.
* find any **bugs in solidtime**, please feel free to [**open an issue**](https://github.com/solidtime-io/solidtime/issues/new) in this repository, with instructions on how to reproduce the bug. If you have a **feature request**, please [**create a discussion**](https://github.com/solidtime-io/solidtime/discussions/new?category=feature-requests) in this repository.
* have a **feature request**, please [**create a discussion**](https://github.com/solidtime-io/solidtime/discussions/new?category=feature-requests) in this repository.
> **Do not use AI-generated text in issues, discussions and PR descriptions.**
>
> We are human, so please write in natural language in your issues, discussions, and PR descriptions.
> Don't be a meatproxy. We will ban people who repeatedly violate this rule from further communication on our repositories.
## Contributing ## Contributing

View File

@@ -1,5 +1,5 @@
ARG PHP_VERSION=8.3 ARG PHP_VERSION=8.3
ARG FRANKENPHP_VERSION=1.13 ARG FRANKENPHP_VERSION=1.11
ARG COMPOSER_VERSION=2.8 ARG COMPOSER_VERSION=2.8
ARG APP_ENV ARG APP_ENV
ARG DOCKER_FILES_BASE_PATH="docker/prod/" ARG DOCKER_FILES_BASE_PATH="docker/prod/"

View File

@@ -7,7 +7,6 @@ realpath_cache_ttl = 720
memory_limit = 256M memory_limit = 256M
max_input_time = 5 max_input_time = 5
register_argc_argv = 0 register_argc_argv = 0
session.upload_progress.enabled = 0
date.timezone = ${TZ:-UTC} date.timezone = ${TZ:-UTC}
[Opcache] [Opcache]

View File

@@ -342,89 +342,6 @@ 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 }) => { test('test that editing a goal via the modal works', async ({ page, ctx }) => {
const goal = await createGoalViaApi(ctx, { const goal = await createGoalViaApi(ctx, {
name: 'Old goal name', name: 'Old goal name',

View File

@@ -5,7 +5,7 @@
}, },
"Services": { "Services": {
"repository": "solidtime-io/extension-services", "repository": "solidtime-io/extension-services",
"ref": "v0.0.6" "ref": "v0.0.4"
}, },
"Invoicing": { "Invoicing": {
"repository": "solidtime-io/extension-invoicing", "repository": "solidtime-io/extension-invoicing",

View File

@@ -29,7 +29,6 @@ import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue'
import TaskCreateModal from '@/Components/Common/Task/TaskCreateModal.vue'; import TaskCreateModal from '@/Components/Common/Task/TaskCreateModal.vue';
import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue'; import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue'; import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
import MemberInviteModal from '@/Components/Common/Member/MemberInviteModal.vue'; import MemberInviteModal from '@/Components/Common/Member/MemberInviteModal.vue';
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue'; import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
@@ -54,7 +53,6 @@ const {
showCreateTaskModal, showCreateTaskModal,
showCreateTagModal, showCreateTagModal,
showCreateGoalModal, showCreateGoalModal,
showGoalUpgradeModal,
showInviteMemberModal, showInviteMemberModal,
showCreateTimeEntryModal, showCreateTimeEntryModal,
showProjectSelector, showProjectSelector,
@@ -187,7 +185,6 @@ const firstProjectId = computed(() => projects.value[0]?.id ?? '');
<!-- Goal Create Modal --> <!-- Goal Create Modal -->
<GoalCreateModal v-model:show="showCreateGoalModal" /> <GoalCreateModal v-model:show="showCreateGoalModal" />
<UpgradeModal v-model:show="showGoalUpgradeModal" feature="goals" />
<!-- Member Invite Modal --> <!-- Member Invite Modal -->
<MemberInviteModal v-model:show="showInviteMemberModal" :available-roles="availableRoles" /> <MemberInviteModal v-model:show="showInviteMemberModal" :available-roles="availableRoles" />

View File

@@ -1,43 +0,0 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import GoalCreateButton from './GoalCreateButton.vue';
function mountGoalCreateButton(canCreateMoreGoals: boolean) {
return mount(GoalCreateButton, {
props: { canCreateMoreGoals },
slots: { default: 'Create your first goal' },
global: {
stubs: {
GoalCreateModal: { name: 'GoalCreateModal', props: ['show'], template: '<div />' },
UpgradeModal: {
name: 'UpgradeModal',
props: ['show', 'feature'],
template: '<div />',
},
},
},
});
}
describe('GoalCreateButton', () => {
it('opens the create modal while more goals can be created', async () => {
const wrapper = mountGoalCreateButton(true);
await wrapper.get('button').trigger('click');
expect(wrapper.getComponent({ name: 'GoalCreateModal' }).props('show')).toBe(true);
expect(wrapper.getComponent({ name: 'UpgradeModal' }).props('show')).toBe(false);
});
it('opens the upgrade modal once the goal limit is reached', async () => {
const wrapper = mountGoalCreateButton(false);
expect(wrapper.get('button').text()).toBe('Create your first goal');
await wrapper.get('button').trigger('click');
const upgradeModal = wrapper.getComponent({ name: 'UpgradeModal' });
expect(upgradeModal.props('show')).toBe(true);
expect(upgradeModal.props('feature')).toBe('goals');
expect(wrapper.getComponent({ name: 'GoalCreateModal' }).props('show')).toBe(false);
});
});

View File

@@ -1,31 +0,0 @@
<script setup lang="ts">
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { ref } from 'vue';
const props = defineProps<{
canCreateMoreGoals: boolean;
}>();
const showCreateGoalModal = ref(false);
const showUpgradeModal = ref(false);
// The button stays visible when the goal limit is reached and explains the limit instead
function onClick() {
if (props.canCreateMoreGoals) {
showCreateGoalModal.value = true;
} else {
showUpgradeModal.value = true;
}
}
</script>
<template>
<GoalCreateModal v-model:show="showCreateGoalModal"></GoalCreateModal>
<UpgradeModal v-model:show="showUpgradeModal" feature="goals" />
<SecondaryButton :icon="PlusIcon" @click="onClick">
<slot>Create Goal</slot>
</SecondaryButton>
</template>

View File

@@ -1,9 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { Target } from '@lucide/vue'; import { Target } from '@lucide/vue';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { ref } from 'vue';
import type { Goal } from '@/packages/api/src'; import type { Goal } from '@/packages/api/src';
import GoalTableRow from '@/Components/Common/Goal/GoalTableRow.vue'; import GoalTableRow from '@/Components/Common/Goal/GoalTableRow.vue';
import GoalTableHeading from '@/Components/Common/Goal/GoalTableHeading.vue'; import GoalTableHeading from '@/Components/Common/Goal/GoalTableHeading.vue';
import GoalCreateButton from '@/Components/Common/Goal/GoalCreateButton.vue'; import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
import { canCreateGoals } from '@/utils/permissions'; import { canCreateGoals } from '@/utils/permissions';
import { LoadingSpinner } from '@/packages/ui/src'; import { LoadingSpinner } from '@/packages/ui/src';
import { isGoalsExtensionActivated } from '@/utils/billing'; import { isGoalsExtensionActivated } from '@/utils/billing';
@@ -15,6 +18,7 @@ const props = defineProps<{
showingArchived: boolean; showingArchived: boolean;
}>(); }>();
const showCreateGoalModal = ref(false);
const showTargetColumn = isGoalsExtensionActivated(); const showTargetColumn = isGoalsExtensionActivated();
// Every column gets a share of the free space so the columns are spread evenly over the table // Every column gets a share of the free space so the columns are spread evenly over the table
const gridTemplateColumns = [ const gridTemplateColumns = [
@@ -29,6 +33,7 @@ const gridTemplateColumns = [
</script> </script>
<template> <template>
<GoalCreateModal v-model:show="showCreateGoalModal"></GoalCreateModal>
<div class="flow-root max-w-[100vw] overflow-x-auto"> <div class="flow-root max-w-[100vw] overflow-x-auto">
<div class="inline-block min-w-full align-middle"> <div class="inline-block min-w-full align-middle">
<div data-testid="goal_table" class="grid min-w-full" :style="{ gridTemplateColumns }"> <div data-testid="goal_table" class="grid min-w-full" :style="{ gridTemplateColumns }">
@@ -46,11 +51,14 @@ const gridTemplateColumns = [
<p v-if="canCreateGoals() && !props.showingArchived" class="pb-5"> <p v-if="canCreateGoals() && !props.showingArchived" class="pb-5">
Set yourself a target and track your progress every day, week or month. Set yourself a target and track your progress every day, week or month.
</p> </p>
<GoalCreateButton <SecondaryButton
v-if="canCreateGoals() && !props.showingArchived" v-if="
:can-create-more-goals="props.canCreateMoreGoals" canCreateGoals() && !props.showingArchived && props.canCreateMoreGoals
"
:icon="PlusIcon"
@click="showCreateGoalModal = true"
>Create your first goal >Create your first goal
</GoalCreateButton> </SecondaryButton>
</div> </div>
<template v-for="goal in props.goals" :key="goal.id"> <template v-for="goal in props.goals" :key="goal.id">
<GoalTableRow <GoalTableRow

View File

@@ -43,7 +43,6 @@ const deleteMutation = useMutation({
onSuccess: () => { onSuccess: () => {
close(); close();
queryClient.invalidateQueries({ queryKey: ['members'] }); queryClient.invalidateQueries({ queryKey: ['members'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
}, },
}); });

View File

@@ -52,7 +52,6 @@ async function submit() {
'There was an error merging the members.', 'There was an error merging the members.',
() => { () => {
queryClient.invalidateQueries({ queryKey: ['members'] }); queryClient.invalidateQueries({ queryKey: ['members'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
show.value = false; show.value = false;
} }
); );

View File

@@ -2,15 +2,19 @@
import MainContainer from '@/packages/ui/src/MainContainer.vue'; import MainContainer from '@/packages/ui/src/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue'; import AppLayout from '@/Layouts/AppLayout.vue';
import { Target } from '@lucide/vue'; import { Target } from '@lucide/vue';
import { PlusIcon } from '@heroicons/vue/16/solid';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import GoalTable from '@/Components/Common/Goal/GoalTable.vue'; import GoalTable from '@/Components/Common/Goal/GoalTable.vue';
import GoalCreateButton from '@/Components/Common/Goal/GoalCreateButton.vue'; import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
import PageTitle from '@/Components/Common/PageTitle.vue'; import PageTitle from '@/Components/Common/PageTitle.vue';
import UpgradeBadge from '@/packages/ui/src/UpgradeBadge.vue';
import { canCreateGoals } from '@/utils/permissions'; import { canCreateGoals } from '@/utils/permissions';
import { canCreateMoreGoals } from '@/utils/billing'; import { canCreateMoreGoals } from '@/utils/billing';
import { useGoalsQuery } from '@/utils/useGoalsQuery'; import { useGoalsQuery } from '@/utils/useGoalsQuery';
import { TabBar, TabBarItem } from '@/packages/ui/src'; import { TabBar, TabBarItem } from '@/packages/ui/src';
const showCreateGoalModal = ref(false);
const activeTab = ref('active'); const activeTab = ref('active');
const showArchived = computed(() => activeTab.value === 'archived'); const showArchived = computed(() => activeTab.value === 'archived');
@@ -37,9 +41,16 @@ const canCreateMore = computed(() => canCreateMoreGoals(goals.value.length));
</TabBarItem> </TabBarItem>
</TabBar> </TabBar>
</div> </div>
<GoalCreateButton <div class="flex items-center space-x-3">
v-if="canCreateGoals()" <UpgradeBadge v-if="canCreateGoals() && !canCreateMore" feature="goals" />
:can-create-more-goals="canCreateMore"></GoalCreateButton> <SecondaryButton
v-if="canCreateGoals() && canCreateMore"
:icon="PlusIcon"
@click="showCreateGoalModal = true"
>Create Goal
</SecondaryButton>
</div>
<GoalCreateModal v-model:show="showCreateGoalModal"></GoalCreateModal>
</MainContainer> </MainContainer>
<GoalTable <GoalTable
:goals="visibleGoals" :goals="visibleGoals"

View File

@@ -422,7 +422,6 @@ export function useBreakPlacement(deps: BreakPlacementDeps) {
}, },
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
}, },
}); });

View File

@@ -217,7 +217,6 @@ export function useCopyLastWeek(
} }
queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
if (attempted === 0) return; if (attempted === 0) return;

View File

@@ -174,7 +174,6 @@ export function useTimesheetCellMutations(
); );
} finally { } finally {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
} }
} }

View File

@@ -22,8 +22,6 @@ import {
canCreateInvitations, canCreateInvitations,
} from '@/utils/permissions'; } from '@/utils/permissions';
import { isBillingActivated, isInvoicingActivated } from '@/utils/billing'; import { isBillingActivated, isInvoicingActivated } from '@/utils/billing';
import { useQueryClient } from '@tanstack/vue-query';
import { fetchCanCreateMoreGoals } from '@/utils/useGoalsQuery';
import { useTimeEntriesInfiniteQuery } from '@/utils/useTimeEntriesInfiniteQuery'; import { useTimeEntriesInfiniteQuery } from '@/utils/useTimeEntriesInfiniteQuery';
import { useProjectsQuery } from '@/utils/useProjectsQuery'; import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { useClientsQuery } from '@/utils/useClientsQuery'; import { useClientsQuery } from '@/utils/useClientsQuery';
@@ -68,7 +66,6 @@ const showCreateClientModal = ref(false);
const showCreateTaskModal = ref(false); const showCreateTaskModal = ref(false);
const showCreateTagModal = ref(false); const showCreateTagModal = ref(false);
const showCreateGoalModal = ref(false); const showCreateGoalModal = ref(false);
const showGoalUpgradeModal = ref(false);
const showInviteMemberModal = ref(false); const showInviteMemberModal = ref(false);
const showCreateTimeEntryModal = ref(false); const showCreateTimeEntryModal = ref(false);
@@ -106,7 +103,6 @@ const ENTITY_ICONS: Record<string, typeof FolderIcon> = {
}; };
export function useCommandPalette() { export function useCommandPalette() {
const queryClient = useQueryClient();
const currentTimeEntryStore = useCurrentTimeEntryStore(); const currentTimeEntryStore = useCurrentTimeEntryStore();
const { currentTimeEntry, isActive } = storeToRefs(currentTimeEntryStore); const { currentTimeEntry, isActive } = storeToRefs(currentTimeEntryStore);
const { setActiveState, updateTimer } = currentTimeEntryStore; const { setActiveState, updateTimer } = currentTimeEntryStore;
@@ -244,13 +240,9 @@ export function useCommandPalette() {
showCreateTagModal.value = true; showCreateTagModal.value = true;
} }
async function openCreateGoalModal() { function openCreateGoalModal() {
closePaletteAfterAction(); closePaletteAfterAction();
if (await fetchCanCreateMoreGoals(queryClient)) { showCreateGoalModal.value = true;
showCreateGoalModal.value = true;
} else {
showGoalUpgradeModal.value = true;
}
} }
function openInviteMemberModal() { function openInviteMemberModal() {
@@ -533,7 +525,6 @@ export function useCommandPalette() {
showCreateTaskModal, showCreateTaskModal,
showCreateTagModal, showCreateTagModal,
showCreateGoalModal, showCreateGoalModal,
showGoalUpgradeModal,
showInviteMemberModal, showInviteMemberModal,
showCreateTimeEntryModal, showCreateTimeEntryModal,
showProjectSelector, showProjectSelector,

View File

@@ -261,7 +261,6 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
stopLiveTimer(); stopLiveTimer();
} }
} }
queryClient.invalidateQueries({ queryKey: ['goals'] });
} else { } else {
throw new Error( throw new Error(
'Failed to fetch current time entry because organization ID is missing.' 'Failed to fetch current time entry because organization ID is missing.'

View File

@@ -1,10 +1,9 @@
import { useQuery, useQueryClient, type QueryClient } from '@tanstack/vue-query'; import { useQuery, useQueryClient } from '@tanstack/vue-query';
import { api } from '@/packages/api/src'; import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import type { Goal } from '@/packages/api/src'; import type { Goal } from '@/packages/api/src';
import { computed } from 'vue'; import { computed } from 'vue';
import { fetchAllPages } from '@/utils/fetchAllPages'; import { fetchAllPages } from '@/utils/fetchAllPages';
import { canCreateMoreGoals, goalLimit } from '@/utils/billing';
export type GoalArchivedFilter = 'true' | 'false' | 'all'; export type GoalArchivedFilter = 'true' | 'false' | 'all';
@@ -20,38 +19,19 @@ export async function fetchAllGoals(
); );
} }
export function goalsQueryOptions(archived: GoalArchivedFilter = 'false') { export function useGoalsQuery(archived: GoalArchivedFilter = 'false') {
return { const queryClient = useQueryClient();
queryKey: ['goals', getCurrentOrganizationId(), archived],
const query = useQuery({
queryKey: computed(() => ['goals', getCurrentOrganizationId(), archived]),
queryFn: async () => { queryFn: async () => {
const organizationId = getCurrentOrganizationId(); const organizationId = getCurrentOrganizationId();
if (!organizationId) throw new Error('No organization'); if (!organizationId) throw new Error('No organization');
const data = await fetchAllGoals(organizationId, archived); const data = await fetchAllGoals(organizationId, archived);
return { data }; return { data };
}, },
staleTime: 1000 * 30, // 30 seconds
};
}
/**
* Checks the goal limit outside of the goals page, where the goals are not loaded yet.
* Archived goals count towards the limit, so all goals are counted.
*/
export async function fetchCanCreateMoreGoals(queryClient: QueryClient): Promise<boolean> {
if (goalLimit() === null) {
return true;
}
const { data } = await queryClient.fetchQuery(goalsQueryOptions('all'));
return canCreateMoreGoals(data.length);
}
export function useGoalsQuery(archived: GoalArchivedFilter = 'false') {
const queryClient = useQueryClient();
const query = useQuery({
...goalsQueryOptions(archived),
queryKey: computed(() => goalsQueryOptions(archived).queryKey),
enabled: () => !!getCurrentOrganizationId(), enabled: () => !!getCurrentOrganizationId(),
staleTime: 1000 * 30, // 30 seconds
// Progress is computed server side, keep it fresh while the page is open // Progress is computed server side, keep it fresh while the page is open
refetchInterval: 1000 * 60, refetchInterval: 1000 * 60,
}); });

View File

@@ -36,7 +36,6 @@ export function useTimeEntriesMutations() {
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
}, },
}); });
@@ -59,7 +58,6 @@ export function useTimeEntriesMutations() {
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
}, },
}); });
@@ -107,7 +105,6 @@ export function useTimeEntriesMutations() {
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
}, },
}); });
@@ -130,7 +127,6 @@ export function useTimeEntriesMutations() {
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
}, },
}); });
@@ -156,7 +152,6 @@ export function useTimeEntriesMutations() {
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
}, },
}); });