Add goals feature for personal goals with filters

This commit is contained in:
Gregor Vostrak
2026-10-02 12:59:44 +02:00
parent 5010a5be3a
commit 38e33b318e
83 changed files with 7702 additions and 105 deletions

View File

@@ -412,6 +412,94 @@ const ProjectMemberUpdateRequest = z
.object({ billable_rate: z.union([z.number(), z.null()]) })
.partial()
.passthrough();
const GoalComparison = z.enum(['at_least', 'less_than']);
const GoalPeriod = z.enum(['day', 'week', 'month']);
const GoalType = z.enum(['personal', 'organization']);
const Weekday = z.enum([
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
]);
const GoalStatus = z.enum(['in_progress', 'achieved', 'on_track', 'exceeded']);
const GoalResource = z
.object({
id: z.string(),
name: z.string(),
type: GoalType,
comparison: GoalComparison,
target_seconds: z.number().int(),
period: GoalPeriod,
member_id: z.union([z.string(), z.null()]),
member_name: z.union([z.string(), z.null()]),
timezone: z.string(),
week_start: Weekday,
is_archived: z.boolean(),
filters: z
.object({
member_ids: z.union([z.array(z.string()), z.null()]),
project_ids: z.union([z.array(z.string()), z.null()]),
task_ids: z.union([z.array(z.string()), z.null()]),
tag_ids: z.union([z.array(z.string()), z.null()]),
tag_match_type: z.union([z.enum(['contains', 'not_contains']), z.null()]),
client_ids: z.union([z.array(z.string()), z.null()]),
billable: z.union([z.boolean(), z.null()]),
time_entry_type: z.union([z.enum(['work', 'break']), z.null()]),
})
.passthrough(),
progress: z
.object({
period_start: z.string(),
period_end: z.string(),
tracked_seconds: z.number().int(),
status: GoalStatus,
})
.passthrough(),
created_at: z.string(),
updated_at: z.string(),
})
.passthrough();
const GoalFiltersRequest = z
.object({
member_ids: z.union([z.array(z.string().uuid()), z.null()]).optional(),
project_ids: z.union([z.array(z.string()), z.null()]).optional(),
task_ids: z.union([z.array(z.string()), z.null()]).optional(),
tag_ids: z.union([z.array(z.string()), z.null()]).optional(),
tag_match_type: z.union([z.enum(['contains', 'not_contains']), z.null()]).optional(),
client_ids: z.union([z.array(z.string()), z.null()]).optional(),
billable: z.union([z.boolean(), z.null()]).optional(),
time_entry_type: z.union([z.enum(['work', 'break']), z.null()]).optional(),
})
.passthrough();
const GoalStoreRequest = z
.object({
name: z.string().max(255),
type: GoalType,
comparison: GoalComparison,
target_seconds: z.number().int().gte(1).lte(2147483647),
period: GoalPeriod,
member_id: z.union([z.string().uuid(), z.null()]).optional(),
timezone: z.union([z.string(), z.null()]).optional(),
week_start: z.union([Weekday, z.null()]).optional(),
filters: GoalFiltersRequest.optional(),
})
.passthrough();
const GoalUpdateRequest = z
.object({
name: z.string().max(255),
comparison: GoalComparison,
target_seconds: z.number().int().gte(1).lte(2147483647),
period: GoalPeriod,
timezone: z.string(),
week_start: Weekday,
is_archived: z.boolean(),
filters: GoalFiltersRequest,
})
.partial()
.passthrough();
const ReportResource = z
.object({
id: z.string(),
@@ -439,15 +527,6 @@ const TimeEntryAggregationType = z.enum([
'type',
]);
const TimeEntryAggregationTypeInterval = z.enum(['day', 'week', 'month', 'year']);
const Weekday = z.enum([
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
]);
const TimeEntryRoundingType = z.enum(['up', 'down', 'nearest']);
const ReportStoreRequest = z
.object({
@@ -782,10 +861,18 @@ export const schemas = {
ProjectMemberResource,
ProjectMemberStoreRequest,
ProjectMemberUpdateRequest,
GoalComparison,
GoalPeriod,
GoalType,
Weekday,
GoalStatus,
GoalResource,
GoalFiltersRequest,
GoalStoreRequest,
GoalUpdateRequest,
ReportResource,
TimeEntryAggregationType,
TimeEntryAggregationTypeInterval,
Weekday,
TimeEntryRoundingType,
ReportStoreRequest,
DetailedReportResource,
@@ -833,6 +920,13 @@ const endpoints = makeApi([
z.object({ code: z.string(), name: z.string(), symbol: z.string() }).passthrough()
),
},
{
method: 'get',
path: '/v1/time-zones',
alias: 'getTimezones',
requestFormat: 'json',
response: z.array(z.object({ key: z.string() }).passthrough()),
},
{
method: 'post',
path: '/v1/organizations',
@@ -3182,6 +3276,254 @@ const endpoints = makeApi([
},
],
},
{
method: 'get',
path: '/v1/organizations/:organization/goals',
alias: 'getGoals',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'type',
type: 'Query',
schema: z.union([GoalType, z.null()]).optional(),
},
{
name: 'archived',
type: 'Query',
schema: z.union([z.enum(['true', 'false', 'all']), z.null()]).optional(),
},
{
name: 'page',
type: 'Query',
schema: z.number().int().gte(1).lte(2147483647).optional(),
},
],
response: z
.object({
data: z.array(GoalResource),
links: z
.object({
first: z.union([z.string(), z.null()]),
last: z.union([z.string(), z.null()]),
prev: z.union([z.string(), z.null()]),
next: z.union([z.string(), z.null()]),
})
.passthrough(),
meta: z
.object({
current_page: z.number().int(),
from: z.union([z.number(), z.null()]),
last_page: z.number().int(),
links: z.array(
z
.object({
url: z.union([z.string(), z.null()]),
label: z.string(),
active: z.boolean(),
})
.passthrough()
),
path: z.union([z.string(), z.null()]),
per_page: z.number().int(),
to: z.union([z.number(), z.null()]),
total: z.number().int(),
})
.passthrough(),
})
.passthrough(),
errors: [
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
},
{
method: 'post',
path: '/v1/organizations/:organization/goals',
alias: 'createGoal',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: GoalStoreRequest,
},
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: GoalResource }).passthrough(),
errors: [
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
},
{
method: 'get',
path: '/v1/organizations/:organization/goals/:goal',
alias: 'getGoal',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'goal',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: GoalResource }).passthrough(),
errors: [
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
],
},
{
method: 'put',
path: '/v1/organizations/:organization/goals/:goal',
alias: 'updateGoal',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: GoalUpdateRequest,
},
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'goal',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: GoalResource }).passthrough(),
errors: [
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
},
{
method: 'delete',
path: '/v1/organizations/:organization/goals/:goal',
alias: 'deleteGoal',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'goal',
type: 'Path',
schema: z.string(),
},
],
response: z.void(),
errors: [
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
],
},
{
method: 'get',
path: '/v1/organizations/:organization/reports',