feat: saved views of my work, across my projects

A person saves named views of «my work»: projects, assigned to me, that I
follow, status categories, priorities, bugs only, overdue and text. They
are private (Mongo my_views, no history), listed and deleted by their
owner (GraphQL myViews / saveMyView / deleteMyView, REST /api/me/views),
and run as the cross-project search, which gains project, priority,
bugsOnly and overdue filters (also in MCP my_issues). The home page shows
them as a bar above «my work», with a drawer to save a new one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 14:22:20 +02:00
parent e14f445e6b
commit 5b0396bc10
46 changed files with 2648 additions and 35 deletions

View File

@@ -784,3 +784,39 @@ export class PushSubscriptionInput {
@Field() @IsString() @MaxLength(200) p256dh!: string;
@Field() @IsString() @MaxLength(100) auth!: string;
}
@InputType()
export class MyViewCriteriaInput {
@Field(() => [ID], { nullable: true, description: 'Empty: all my projects' })
@IsOptional()
@IsArray()
@ArrayMaxSize(50)
@IsUUID('all', { each: true })
projectIds?: string[];
@Field({ nullable: true }) @IsOptional() @IsBoolean() assignedToMe?: boolean;
@Field({ nullable: true }) @IsOptional() @IsBoolean() watching?: boolean;
@Field(() => [StatusCategory], { nullable: true })
@IsOptional()
@IsEnum(StatusCategory, { each: true })
categories?: StatusCategory[];
@Field(() => [IssuePriority], { nullable: true })
@IsOptional()
@IsEnum(IssuePriority, { each: true })
priorities?: IssuePriority[];
@Field({ nullable: true }) @IsOptional() @IsBoolean() bugsOnly?: boolean;
@Field({ nullable: true }) @IsOptional() @IsBoolean() overdue?: boolean;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
@MaxLength(200)
search?: string | null;
}
@InputType()
export class SaveMyViewInput {
@Field() @IsString() @MinLength(1) @MaxLength(60) name!: string;
@Field(() => MyViewCriteriaInput)
@ValidateNested()
@Type(() => MyViewCriteriaInput)
criteria!: MyViewCriteriaInput;
}

View File

@@ -20,6 +20,7 @@ import {
WorkflowsService,
} from '@task-stream/api/tracker/application';
import {
IssuePriority,
IssueReadModel,
StatusCategory,
} from '@task-stream/api/tracker/domain';
@@ -121,6 +122,26 @@ export class IssueResolver {
description: 'Matches key, title or description (global search)',
})
search?: string | null,
@Args('projectIds', {
type: () => [ID],
nullable: true,
description: 'Only these of my projects',
})
projectIds?: string[] | null,
@Args('priorities', { type: () => [IssuePriority], nullable: true })
priorities?: IssuePriority[] | null,
@Args('bugsOnly', {
type: () => Boolean,
nullable: true,
description: 'Only types drawn with the bug icon',
})
bugsOnly?: boolean | null,
@Args('overdue', {
type: () => Boolean,
nullable: true,
description: 'Only open tickets past their due date',
})
overdue?: boolean | null,
) {
return this.issuesService.mine(me.id, {
assignedToMe,
@@ -128,6 +149,10 @@ export class IssueResolver {
statuses: statuses ?? undefined,
categories: categories ?? undefined,
search: search ?? undefined,
projectIds: projectIds ?? undefined,
priorities: priorities ?? undefined,
bugsOnly: bugsOnly ?? undefined,
overdue: overdue ?? undefined,
limit,
offset,
});

View File

@@ -2,8 +2,8 @@ import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql';
import { User } from '@task-stream/api/identity/domain';
import { CurrentUser } from '@task-stream/api/identity/presenters';
import { SavedFiltersService } from '@task-stream/api/tracker/application';
import { SaveFilterInput } from './inputs';
import { SavedFilterGql } from './types';
import { SaveFilterInput, SaveMyViewInput } from './inputs';
import { MyViewGql, SavedFilterGql } from './types';
/** Saved views of a project's board. */
@Resolver(() => SavedFilterGql)
@@ -41,4 +41,36 @@ export class SavedFilterResolver {
await this.filters.delete(me.id, id);
return true;
}
@Query(() => [MyViewGql], {
description: 'My saved views of my work, across my projects',
})
myViews(@CurrentUser() me: User) {
return this.filters.myViews(me.id);
}
@Mutation(() => ID, { description: 'Save a view of my work under a name' })
async saveMyView(
@CurrentUser() me: User,
@Args('input') input: SaveMyViewInput,
) {
return (
await this.filters.saveMyView(me.id, {
name: input.name,
criteria: {
...input.criteria,
search: input.criteria.search ?? undefined,
},
})
).id;
}
@Mutation(() => Boolean, { description: 'Delete one of my views' })
async deleteMyView(
@CurrentUser() me: User,
@Args('id', { type: () => ID }) id: string,
) {
await this.filters.deleteMyView(me.id, id);
return true;
}
}

View File

@@ -456,6 +456,34 @@ export class SavedFilterGql {
@Field() createdAt!: string;
}
@ObjectType('MyViewCriteria', {
description:
'What a view of my work narrows to; empty: every ticket of my projects',
})
export class MyViewCriteriaGql {
@Field(() => [ID], { nullable: true, description: 'Empty: all my projects' })
projectIds?: string[];
@Field({ nullable: true }) assignedToMe?: boolean;
@Field({ nullable: true }) watching?: boolean;
@Field(() => [StatusCategory], { nullable: true })
categories?: StatusCategory[];
@Field(() => [IssuePriority], { nullable: true })
priorities?: IssuePriority[];
@Field({ nullable: true, description: 'Only types with the bug icon' })
bugsOnly?: boolean;
@Field({ nullable: true, description: 'Only open tickets past due' })
overdue?: boolean;
@Field(() => String, { nullable: true }) search?: string | null;
}
@ObjectType('MyView', { description: 'A named view of my work (private)' })
export class MyViewGql {
@Field(() => ID) id!: string;
@Field() name!: string;
@Field(() => MyViewCriteriaGql) criteria!: MyViewCriteriaGql;
@Field() createdAt!: string;
}
@ObjectType('IssuePage')
export class IssuePageGql {
@Field(() => [IssueGql]) items!: IssueGql[];