Compare commits

..

23 Commits

Author SHA1 Message Date
Constantin Graf
5d051fb062 Build FrankenPHP on patched PHP 8.3.36 base 2026-10-21 19:26:34 +02:00
Constantin Graf
c8a300e1b6 Update FrankenPHP to 1.13 2026-10-21 15:57:31 +02:00
Constantin Graf
eed654b9c8 Disable session upload progress in production PHP config 2026-10-21 11:34:51 +02:00
Gregor Vostrak
68833a5125 Enhance contribution guidelines 2026-10-21 00:50:27 +02:00
Constantin Graf
735f3b8d39 Update services extension to v0.0.6 2026-10-20 23:29:49 +02:00
Gregor Vostrak
9caf911795 improve README with issue reporting guidelines and formatting 2026-10-20 23:12:15 +02:00
Gregor Vostrak
c55afd234e Add guideline against AI-generated text usage 2026-10-20 23:08:23 +02:00
Gregor Vostrak
20cc5f9e5b Update CONTRIBUTING.md with new AI rules for issues, PRs and discussions 2026-10-20 23:06:49 +02:00
Constantin Graf
2df7a2845e Update services extension to v0.0.5 2026-10-20 21:45:18 +02:00
Constantin Graf
510cb10cb9 Deploy API docs with the FastFront GitHub action 2026-10-19 16:54:33 +02:00
Gregor Vostrak
b46f015754 Always show the create goal button and open the upgrade modal when the goal limit is reached 2026-10-14 16:59:51 +02:00
Gregor Vostrak
fc8985f513 Invalidate goals after time entry and member changes 2026-10-14 16:59:51 +02:00
Constantin Graf
812b3f2f54 Update billing extension to v0.0.11 2026-10-14 15:15:24 +02:00
Constantin Graf
4b25b2adfa Mention all import sources and warn about billable data not being imported on free plan 2026-10-14 15:15:24 +02:00
Constantin Graf
9557296eb0 Skip enabled extensions without vite config in vite module loader 2026-10-14 15:15:24 +02:00
Constantin Graf
c69edc81be Add tests for billable rate restriction in importers and API edge cases 2026-10-14 15:15:24 +02:00
Constantin Graf
902270a189 Only check billable rate restriction if time entry becomes billable 2026-10-14 15:15:24 +02:00
Constantin Graf
f60c9ca457 Move billable rate restriction tests to the endpoint tests of the specific endpoints 2026-10-14 15:15:24 +02:00
Constantin Graf
1c41432389 Enforce billable rate restriction in controllers and importers instead of model hooks 2026-10-14 15:15:24 +02:00
Gregor Vostrak
3197c8c7a0 update invoicing and billing extensions to tagged releases 2026-10-14 15:15:24 +02:00
Gregor Vostrak
b8761dffce load billable rate limits extension branches for billing and invoicing 2026-10-14 15:15:24 +02:00
Gregor Vostrak
ba2b37f264 add billable rate restriction for free organizations 2026-10-14 15:15:24 +02:00
Gregor Vostrak
9e6013eaf1 move upgrade modal to ui package and add trial status and cancel trial ui 2026-10-14 15:15:24 +02:00
21 changed files with 341 additions and 52 deletions

View File

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

View File

@@ -6,7 +6,19 @@ 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 enhancement 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 enhancements 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,8 +30,16 @@ 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 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
If you have a **feature request**, please [**create a discussion**](https://github.com/solidtime-io/solidtime/discussions/new?category=feature-requests) in this repository. * 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.
* 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,12 +1,90 @@
ARG PHP_VERSION=8.3 # syntax=docker/dockerfile:1
ARG FRANKENPHP_VERSION=1.11 ARG PHP_VERSION=8.3.36
ARG GO_VERSION=1.27
# FrankenPHP v1.13.1
ARG FRANKENPHP_REF=99d4e119ca2e5a90a69d8dbee5d5c9a8016416c5
ARG WATCHER_VERSION=0.14.5
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/"
FROM composer:${COMPOSER_VERSION} AS vendor FROM composer:${COMPOSER_VERSION} AS vendor
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-builder-php${PHP_VERSION} AS upstream FROM golang:${GO_VERSION}-trixie AS golang-base
# Rebuild of the official dunglas/frankenphp images on a patched PHP base.
# Mirrors the "common", "builder" and "runner" stages of https://github.com/php/frankenphp/blob/v1.13.1/Dockerfile
FROM scratch AS frankenphp-src
ARG FRANKENPHP_REF
ADD https://github.com/php/frankenphp.git#${FRANKENPHP_REF} /
FROM php:${PHP_VERSION}-zts-trixie AS frankenphp-common
WORKDIR /app
RUN apt-get update && \
apt-get -y --no-install-recommends install \
mailcap \
libcap2-bin \
&& \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
RUN set -eux; \
mkdir -p \
/app/public \
/config/caddy \
/data/caddy \
/etc/caddy \
/etc/frankenphp; \
sed -i 's/php/frankenphp run/g' /usr/local/bin/docker-php-entrypoint
COPY --link --from=frankenphp-src /caddy/frankenphp/Caddyfile /etc/caddy/Caddyfile
COPY --link --from=frankenphp-src /package/content/index.php /app/public/index.php
RUN ln /etc/caddy/Caddyfile /etc/frankenphp/Caddyfile && \
curl -sSLf \
-o /usr/local/bin/install-php-extensions \
https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions && \
chmod +x /usr/local/bin/install-php-extensions
ENV XDG_CONFIG_HOME=/config
ENV XDG_DATA_HOME=/data
FROM frankenphp-common AS upstream
ARG WATCHER_VERSION
COPY --from=golang-base /usr/local/go /usr/local/go
ENV PATH=/usr/local/go/bin:$PATH
ENV GOTOOLCHAIN=local
RUN apt-get update && \
apt-get -y --no-install-recommends install \
cmake \
git \
libargon2-dev \
libbrotli-dev \
libcurl4-openssl-dev \
libonig-dev \
libreadline-dev \
libsodium-dev \
libsqlite3-dev \
libssl-dev \
libxml2-dev \
zlib1g-dev \
&& \
apt-get clean
WORKDIR /usr/local/src/watcher
RUN curl -fsSL "https://github.com/e-dant/watcher/archive/refs/tags/${WATCHER_VERSION}.tar.gz" | tar xz --strip-components 1 && \
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && \
cmake --build build && \
cmake --install build && \
ldconfig
WORKDIR /go/src/app
COPY --link --from=frankenphp-src / ./
COPY --from=caddy:builder /usr/bin/xcaddy /usr/bin/xcaddy COPY --from=caddy:builder /usr/bin/xcaddy /usr/bin/xcaddy
@@ -21,7 +99,16 @@ RUN CGO_ENABLED=1 \
--with github.com/dunglas/frankenphp/caddy=./caddy/ \ --with github.com/dunglas/frankenphp/caddy=./caddy/ \
--with github.com/dunglas/caddy-cbrotli --with github.com/dunglas/caddy-cbrotli
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION} FROM frankenphp-common
ENV GODEBUG=cgocheck=0
COPY --from=upstream /usr/local/lib/libwatcher* /usr/local/lib/
RUN apt-get update && \
apt-get install -y --no-install-recommends libstdc++6 && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* && \
ldconfig
COPY --from=upstream /usr/local/bin/frankenphp /usr/local/bin/frankenphp COPY --from=upstream /usr/local/bin/frankenphp /usr/local/bin/frankenphp

View File

@@ -7,6 +7,7 @@ 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,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 }) => { 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.4" "ref": "v0.0.6"
}, },
"Invoicing": { "Invoicing": {
"repository": "solidtime-io/extension-invoicing", "repository": "solidtime-io/extension-invoicing",

View File

@@ -29,6 +29,7 @@ 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';
@@ -53,6 +54,7 @@ const {
showCreateTaskModal, showCreateTaskModal,
showCreateTagModal, showCreateTagModal,
showCreateGoalModal, showCreateGoalModal,
showGoalUpgradeModal,
showInviteMemberModal, showInviteMemberModal,
showCreateTimeEntryModal, showCreateTimeEntryModal,
showProjectSelector, showProjectSelector,
@@ -185,6 +187,7 @@ 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

@@ -0,0 +1,43 @@
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

@@ -0,0 +1,31 @@
<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,12 +1,9 @@
<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 GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue'; import GoalCreateButton from '@/Components/Common/Goal/GoalCreateButton.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';
@@ -18,7 +15,6 @@ 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 = [
@@ -33,7 +29,6 @@ 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 }">
@@ -51,14 +46,11 @@ 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>
<SecondaryButton <GoalCreateButton
v-if=" v-if="canCreateGoals() && !props.showingArchived"
canCreateGoals() && !props.showingArchived && props.canCreateMoreGoals :can-create-more-goals="props.canCreateMoreGoals"
"
:icon="PlusIcon"
@click="showCreateGoalModal = true"
>Create your first goal >Create your first goal
</SecondaryButton> </GoalCreateButton>
</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,6 +43,7 @@ const deleteMutation = useMutation({
onSuccess: () => { onSuccess: () => {
close(); close();
queryClient.invalidateQueries({ queryKey: ['members'] }); queryClient.invalidateQueries({ queryKey: ['members'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
}, },
}); });

View File

@@ -52,6 +52,7 @@ 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,19 +2,15 @@
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 GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue'; import GoalCreateButton from '@/Components/Common/Goal/GoalCreateButton.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');
@@ -41,16 +37,9 @@ const canCreateMore = computed(() => canCreateMoreGoals(goals.value.length));
</TabBarItem> </TabBarItem>
</TabBar> </TabBar>
</div> </div>
<div class="flex items-center space-x-3"> <GoalCreateButton
<UpgradeBadge v-if="canCreateGoals() && !canCreateMore" feature="goals" /> v-if="canCreateGoals()"
<SecondaryButton :can-create-more-goals="canCreateMore"></GoalCreateButton>
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,6 +422,7 @@ export function useBreakPlacement(deps: BreakPlacementDeps) {
}, },
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
}, },
}); });

View File

@@ -217,6 +217,7 @@ 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,6 +174,7 @@ export function useTimesheetCellMutations(
); );
} finally { } finally {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
} }
} }

View File

@@ -22,6 +22,8 @@ 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';
@@ -66,6 +68,7 @@ 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);
@@ -103,6 +106,7 @@ 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;
@@ -240,9 +244,13 @@ export function useCommandPalette() {
showCreateTagModal.value = true; showCreateTagModal.value = true;
} }
function openCreateGoalModal() { async function openCreateGoalModal() {
closePaletteAfterAction(); closePaletteAfterAction();
showCreateGoalModal.value = true; if (await fetchCanCreateMoreGoals(queryClient)) {
showCreateGoalModal.value = true;
} else {
showGoalUpgradeModal.value = true;
}
} }
function openInviteMemberModal() { function openInviteMemberModal() {
@@ -525,6 +533,7 @@ export function useCommandPalette() {
showCreateTaskModal, showCreateTaskModal,
showCreateTagModal, showCreateTagModal,
showCreateGoalModal, showCreateGoalModal,
showGoalUpgradeModal,
showInviteMemberModal, showInviteMemberModal,
showCreateTimeEntryModal, showCreateTimeEntryModal,
showProjectSelector, showProjectSelector,

View File

@@ -261,6 +261,7 @@ 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,9 +1,10 @@
import { useQuery, useQueryClient } from '@tanstack/vue-query'; import { useQuery, useQueryClient, type QueryClient } 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';
@@ -19,19 +20,38 @@ export async function fetchAllGoals(
); );
} }
export function useGoalsQuery(archived: GoalArchivedFilter = 'false') { export function goalsQueryOptions(archived: GoalArchivedFilter = 'false') {
const queryClient = useQueryClient(); return {
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 };
}, },
enabled: () => !!getCurrentOrganizationId(),
staleTime: 1000 * 30, // 30 seconds 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(),
// 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,6 +36,7 @@ export function useTimeEntriesMutations() {
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
}, },
}); });
@@ -58,6 +59,7 @@ export function useTimeEntriesMutations() {
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
}, },
}); });
@@ -105,6 +107,7 @@ export function useTimeEntriesMutations() {
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
}, },
}); });
@@ -127,6 +130,7 @@ export function useTimeEntriesMutations() {
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
}, },
}); });
@@ -152,6 +156,7 @@ export function useTimeEntriesMutations() {
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
}, },
}); });