Add goals feature for personal goals with filters

This commit is contained in:
Gregor Vostrak
2026-10-02 12:59:44 +02:00
parent 349623d537
commit f9519d0d5a
83 changed files with 7702 additions and 105 deletions

View File

@@ -17,6 +17,14 @@ export function isInvoicingActivated() {
return page.props.has_invoicing_extension;
}
export function isGoalsExtensionActivated() {
const page = usePage<{
has_goals_extension: boolean;
}>();
return page.props.has_goals_extension;
}
export function isInTrial() {
const page = usePage<{
billing: {
@@ -49,6 +57,26 @@ export function isBlocked() {
return page.props.billing.is_blocked;
}
/**
* The maximum number of goals the organization can have, null means unlimited.
* Archived goals count towards the limit as well.
*/
export function goalLimit(): number | null {
const page = usePage<{
billing: {
goal_limit: number | null;
} | null;
}>();
return page.props.billing?.goal_limit ?? null;
}
export function canCreateMoreGoals(goalCount: number): boolean {
const limit = goalLimit();
return limit === null || goalCount < limit;
}
export function isFreePlan() {
return !hasActiveSubscription() && !isInTrial();
}

View File

@@ -23,6 +23,7 @@ import {
ClipboardDocumentListIcon,
BuildingOfficeIcon,
} from '@heroicons/vue/20/solid';
import { Target } from '@lucide/vue';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import type { Organization } from '@/types/models';
@@ -61,6 +62,7 @@ export function createNavigationCommands(
canViewTags: () => boolean;
canViewReport: () => boolean;
canViewInvoices: () => boolean;
canViewGoals: () => boolean;
canManageBilling: () => boolean;
canUpdateOrganization: () => boolean;
},
@@ -126,6 +128,16 @@ export function createNavigationCommands(
permission: permissions.canViewReport,
priority: GROUP_PRIORITIES.navigation + 5,
},
{
id: 'nav-goals',
label: 'Go to Goals',
icon: Target,
keywords: ['goals', 'targets', 'habits', 'progress'],
group: 'navigation',
action: () => navigate('goals'),
permission: permissions.canViewGoals,
priority: GROUP_PRIORITIES.navigation + 4.5,
},
{
id: 'nav-projects',
label: 'Go to Projects',
@@ -385,6 +397,7 @@ export function createCreateCommands(
openClientModal: () => void;
openTaskModal: () => void;
openTagModal: () => void;
openGoalModal: () => void;
openInviteModal: () => void;
},
permissions: {
@@ -392,6 +405,7 @@ export function createCreateCommands(
canCreateClients: () => boolean;
canCreateTasks: () => boolean;
canCreateTags: () => boolean;
canCreateGoals: () => boolean;
canCreateInvitations: () => boolean;
}
): Command[] {
@@ -436,6 +450,16 @@ export function createCreateCommands(
permission: permissions.canCreateTags,
priority: GROUP_PRIORITIES.create + 2,
},
{
id: 'create-goal',
label: 'Create Goal',
icon: Target,
keywords: ['new goal', 'add goal', 'create', 'target'],
group: 'create',
action: createActions.openGoalModal,
permission: permissions.canCreateGoals,
priority: GROUP_PRIORITIES.create + 1.5,
},
{
id: 'create-invite',
label: 'Invite Member',

View File

@@ -0,0 +1,63 @@
import type { Goal, GoalComparison, GoalPeriod, GoalStatus } from '@/packages/api/src';
import { canDeleteOrganizationGoals, canUpdateOrganizationGoals } from '@/utils/permissions';
import { getCurrentMembershipId } from '@/utils/useUser';
export const goalComparisonOptions: { value: GoalComparison; label: string }[] = [
{ value: 'at_least', label: 'At least' },
{ value: 'less_than', label: 'Less than' },
];
export const goalPeriodOptions: { value: GoalPeriod; label: string }[] = [
{ value: 'day', label: 'per day' },
{ value: 'week', label: 'per week' },
{ value: 'month', label: 'per month' },
];
/**
* Who a goal is for. The three answers map to the goal type and the member of the goal:
* me = a personal goal for yourself, other = an organization goal for one member,
* organization = an organization goal that counts every member.
*/
export type GoalTarget = 'me' | 'other' | 'organization';
export const goalTargetOptions: { value: GoalTarget; label: string }[] = [
{ value: 'me', label: 'For me' },
{ value: 'other', label: 'For someone else' },
{ value: 'organization', label: 'Organization goal' },
];
/**
* Mirrors the access rules on the server: a personal goal belongs to its member, an organization goal
* to the members that may manage goals. The member an organization goal is for never manages it.
*/
export function canManageGoal(goal: Goal): boolean {
if (goal.type === 'personal') {
return goal.member_id === getCurrentMembershipId();
}
return canUpdateOrganizationGoals() || canDeleteOrganizationGoals();
}
export function goalTypeLabel(goal: Goal): string {
return goal.type === 'personal' ? 'Personal goal' : 'Organization goal';
}
export function goalComparisonLabel(comparison: GoalComparison): string {
return goalComparisonOptions.find((option) => option.value === comparison)?.label ?? comparison;
}
export function goalStatusLabel(status: GoalStatus): string {
switch (status) {
case 'in_progress':
return 'In progress';
case 'achieved':
return 'Achieved';
case 'on_track':
return 'On track';
case 'exceeded':
return 'Exceeded';
}
}
export function goalStatusIsPositive(status: GoalStatus): boolean {
return status === 'achieved' || status === 'on_track';
}

View File

@@ -132,3 +132,43 @@ export function canViewInvoices() {
export function canCreateReports() {
return currentUserHasPermission('reports:create');
}
// Goal permissions are split by the type of goal they reach: `:own` covers the goals a member sets for
// themselves, `:organization-type` the organization goals of the team goals extension. The helpers without a
// suffix answer whether the member reaches at least one of the two, which is what the UI entry points need.
export function canViewOwnGoals() {
return currentUserHasPermission('goals:view:own');
}
export function canViewOrganizationGoals() {
return currentUserHasPermission('goals:view:organization-type');
}
export function canViewGoals() {
return canViewOwnGoals() || canViewOrganizationGoals();
}
export function canCreateOwnGoals() {
return currentUserHasPermission('goals:create:own');
}
export function canCreateOrganizationGoals() {
return currentUserHasPermission('goals:create:organization-type');
}
export function canCreateGoals() {
return canCreateOwnGoals() || canCreateOrganizationGoals();
}
export function canUpdateOwnGoals() {
return currentUserHasPermission('goals:update:own');
}
export function canUpdateOrganizationGoals() {
return currentUserHasPermission('goals:update:organization-type');
}
export function canUpdateGoals() {
return canUpdateOwnGoals() || canUpdateOrganizationGoals();
}
export function canDeleteOwnGoals() {
return currentUserHasPermission('goals:delete:own');
}
export function canDeleteOrganizationGoals() {
return currentUserHasPermission('goals:delete:organization-type');
}
export function canDeleteGoals() {
return canDeleteOwnGoals() || canDeleteOrganizationGoals();
}

View File

@@ -11,12 +11,14 @@ import {
canViewTags,
canViewReport,
canViewInvoices,
canViewGoals,
canManageBilling,
canUpdateOrganization,
canCreateProjects,
canCreateClients,
canCreateTasks,
canCreateTags,
canCreateGoals,
canCreateInvitations,
} from '@/utils/permissions';
import { isBillingActivated, isInvoicingActivated } from '@/utils/billing';
@@ -63,6 +65,7 @@ const showCreateProjectModal = ref(false);
const showCreateClientModal = ref(false);
const showCreateTaskModal = ref(false);
const showCreateTagModal = ref(false);
const showCreateGoalModal = ref(false);
const showInviteMemberModal = ref(false);
const showCreateTimeEntryModal = ref(false);
@@ -237,6 +240,11 @@ export function useCommandPalette() {
showCreateTagModal.value = true;
}
function openCreateGoalModal() {
closePaletteAfterAction();
showCreateGoalModal.value = true;
}
function openInviteMemberModal() {
closePaletteAfterAction();
showInviteMemberModal.value = true;
@@ -259,6 +267,7 @@ export function useCommandPalette() {
canViewTags,
canViewReport,
canViewInvoices,
canViewGoals,
canManageBilling,
canUpdateOrganization,
},
@@ -309,6 +318,7 @@ export function useCommandPalette() {
openClientModal: openCreateClientModal,
openTaskModal: openCreateTaskModal,
openTagModal: openCreateTagModal,
openGoalModal: openCreateGoalModal,
openInviteModal: openInviteMemberModal,
},
{
@@ -316,6 +326,7 @@ export function useCommandPalette() {
canCreateClients,
canCreateTasks,
canCreateTags,
canCreateGoals,
canCreateInvitations,
}
)
@@ -513,6 +524,7 @@ export function useCommandPalette() {
showCreateClientModal,
showCreateTaskModal,
showCreateTagModal,
showCreateGoalModal,
showInviteMemberModal,
showCreateTimeEntryModal,
showProjectSelector,

View File

@@ -205,6 +205,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
stopLiveTimer();
}
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
}
async function resumeWorkAfterBreak(context: ResumeTimeEntryContext) {
@@ -223,6 +224,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
startLiveTimer();
await startTimer();
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
}
async function updateTimer() {
@@ -302,6 +304,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
await stopTimer();
}
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
}
return {

View File

@@ -0,0 +1,126 @@
import { defineStore } from 'pinia';
import { api } from '@/packages/api/src';
import type { CreateGoalBody, Goal, UpdateGoalBody } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import { useQueryClient } from '@tanstack/vue-query';
import { isApiValidationError } from '@/utils/apiValidation';
export const useGoalsStore = defineStore('goals', () => {
const { addNotification, handleApiRequestNotifications } = useNotificationsStore();
const queryClient = useQueryClient();
/**
* Like handleApiRequestNotifications, but a 422 response is rethrown as the AxiosError
* without a toast, so that the goal form can show the errors next to its fields.
*/
async function handleGoalFormRequest<T>(
apiRequest: () => Promise<T>,
successMessage: string,
errorMessage: string
): Promise<T | undefined> {
let validationError: unknown = null;
const response = await handleApiRequestNotifications(
async () => {
try {
return await apiRequest();
} catch (error) {
if (isApiValidationError(error)) {
validationError = error;
return undefined;
}
throw error;
}
},
undefined,
errorMessage
);
if (validationError !== null) {
throw validationError;
}
addNotification('success', successMessage);
return response;
}
async function createGoal(goalBody: CreateGoalBody): Promise<Goal | undefined> {
const organization = getCurrentOrganizationId();
if (organization) {
const response = await handleGoalFormRequest(
() =>
api.createGoal(goalBody, {
params: {
organization: organization,
},
}),
'Goal created successfully',
'Failed to create goal'
);
queryClient.invalidateQueries({ queryKey: ['goals'] });
return response?.data;
}
}
async function updateGoal(goalId: string, goalBody: UpdateGoalBody): Promise<Goal | undefined> {
const organization = getCurrentOrganizationId();
if (organization) {
const response = await handleGoalFormRequest(
() =>
api.updateGoal(goalBody, {
params: {
organization: organization,
goal: goalId,
},
}),
'Goal updated successfully',
'Failed to update goal'
);
queryClient.invalidateQueries({ queryKey: ['goals'] });
return response?.data;
}
}
/**
* Archived goals are hidden from the goal list by default, archiving is not deleting.
*/
async function setGoalArchived(goalId: string, isArchived: boolean): Promise<Goal | undefined> {
const organization = getCurrentOrganizationId();
if (organization) {
const response = await handleApiRequestNotifications(
() =>
api.updateGoal(
{ is_archived: isArchived },
{
params: {
organization: organization,
goal: goalId,
},
}
),
isArchived ? 'Goal archived successfully' : 'Goal unarchived successfully',
isArchived ? 'Failed to archive goal' : 'Failed to unarchive goal'
);
queryClient.invalidateQueries({ queryKey: ['goals'] });
return response?.data;
}
}
async function deleteGoal(goalId: string) {
const organization = getCurrentOrganizationId();
if (organization) {
await handleApiRequestNotifications(
() =>
api.deleteGoal(undefined, {
params: {
organization: organization,
goal: goalId,
},
}),
'Goal deleted successfully',
'Failed to delete goal'
);
queryClient.invalidateQueries({ queryKey: ['goals'] });
}
}
return { createGoal, updateGoal, setGoalArchived, deleteGoal };
});

View File

@@ -0,0 +1,50 @@
import { useQuery, useQueryClient } from '@tanstack/vue-query';
import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import type { Goal } from '@/packages/api/src';
import { computed } from 'vue';
import { fetchAllPages } from '@/utils/fetchAllPages';
export type GoalArchivedFilter = 'true' | 'false' | 'all';
export async function fetchAllGoals(
organizationId: string,
archived: GoalArchivedFilter = 'false'
): Promise<Goal[]> {
return fetchAllPages((page) =>
api.getGoals({
params: { organization: organizationId },
queries: { page, archived },
})
);
}
export function useGoalsQuery(archived: GoalArchivedFilter = 'false') {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: computed(() => ['goals', getCurrentOrganizationId(), archived]),
queryFn: async () => {
const organizationId = getCurrentOrganizationId();
if (!organizationId) throw new Error('No organization');
const data = await fetchAllGoals(organizationId, archived);
return { data };
},
enabled: () => !!getCurrentOrganizationId(),
staleTime: 1000 * 30, // 30 seconds
// Progress is computed server side, keep it fresh while the page is open
refetchInterval: 1000 * 60,
});
const goals = computed<Goal[]>(() => query.data.value?.data ?? []);
const invalidateGoals = () => {
queryClient.invalidateQueries({ queryKey: ['goals'] });
};
return {
...query,
goals,
invalidateGoals,
};
}

View File

@@ -14,7 +14,7 @@ export async function fetchAllMembers(organizationId: string): Promise<Member[]>
);
}
export function useMembersQuery() {
export function useMembersQuery(options: { enabled?: () => boolean } = {}) {
const queryClient = useQueryClient();
const query = useQuery({
@@ -25,7 +25,7 @@ export function useMembersQuery() {
const data = await fetchAllMembers(organizationId);
return { data };
},
enabled: () => !!getCurrentOrganizationId(),
enabled: () => !!getCurrentOrganizationId() && (options.enabled?.() ?? true),
staleTime: 1000 * 30, // 30 seconds
});

View File

@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/vue-query';
import { api } from '@/packages/api/src';
import { computed } from 'vue';
export function useTimezonesQuery() {
const query = useQuery({
queryKey: ['timezones'],
queryFn: () => api.getTimezones(),
// The list of timezones does not change while the app is open
staleTime: Infinity,
});
const timezones = computed<string[]>(
() => query.data.value?.map((timezone) => timezone.key) ?? []
);
return {
...query,
timezones,
};
}

View File

@@ -0,0 +1,11 @@
import type { Weekday } from '@/packages/api/src';
export const weekdayOptions: { value: Weekday; label: string }[] = [
{ value: 'monday', label: 'Monday' },
{ value: 'tuesday', label: 'Tuesday' },
{ value: 'wednesday', label: 'Wednesday' },
{ value: 'thursday', label: 'Thursday' },
{ value: 'friday', label: 'Friday' },
{ value: 'saturday', label: 'Saturday' },
{ value: 'sunday', label: 'Sunday' },
];