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

@@ -29,4 +29,6 @@ export * from './lib/webhooks/webhook-events';
export * from './lib/git/git';
export * from './lib/git/git-payloads';
export * from './lib/saved-filters/saved-filters';
export * from './lib/saved-filters/my-views';
export * from './lib/ports/my-views.repository';
export * from './lib/tracker-application.module';

View File

@@ -60,6 +60,11 @@ export interface CrossProjectIssueFilter {
watcherId?: string;
statuses?: string[];
categories?: StatusCategory[];
priorities?: IssuePriority[];
/** Project id → the only type ids taken there. */
typesByProject?: Record<string, string[]>;
/** Due before this day (`YYYY-MM-DD`, excluded). */
dueBefore?: string;
search?: string;
limit: number;
offset: number;

View File

@@ -0,0 +1,11 @@
import { MyViewReadModel } from '@task-stream/api/tracker/domain';
/** Saved views of «my work»: one person's preferences, no history. */
export abstract class MyViewsRepository {
/** By name. */
abstract findByOwner(ownerId: string): Promise<MyViewReadModel[]>;
abstract findById(id: string): Promise<MyViewReadModel | null>;
abstract countByOwner(ownerId: string): Promise<number>;
abstract insert(view: MyViewReadModel): Promise<void>;
abstract delete(id: string): Promise<void>;
}

View File

@@ -1,5 +1,9 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
import {
DEFAULT_PROJECT_SETTINGS,
IssueReadModel,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import { FindIssuesRepository, Page } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { SearchMyIssuesQuery } from './search-my-issues.query';
@@ -15,13 +19,43 @@ export class SearchMyIssuesQueryHandler
/** Membership is the access rule: only projects the viewer belongs to. */
async execute(query: SearchMyIssuesQuery): Promise<Page<IssueReadModel>> {
const projects = await this.findProjects.findByMember(query.viewerId);
const { filter } = query;
const projects = (
await this.findProjects.findByMember(query.viewerId)
).filter(
(p) => !filter.projectIds?.length || filter.projectIds.includes(p.id),
);
// Bugs are each project's types drawn with the bug icon.
const bugTypes = filter.bugsOnly
? Object.fromEntries(
projects.map((p) => [
p.id,
(p.settings ?? DEFAULT_PROJECT_SETTINGS).issueTypes
.filter((t) => t.icon === 'bug')
.map((t) => t.id),
]),
)
: undefined;
const open = [
StatusCategory.TODO,
StatusCategory.IN_PROGRESS,
StatusCategory.QA,
];
return this.findIssues.searchAcross({
projectIds: projects.map((project) => project.id),
priorities: filter.priorities?.length ? filter.priorities : undefined,
typesByProject: bugTypes,
dueBefore: filter.overdue
? new Date().toISOString().slice(0, 10)
: undefined,
assigneeId: query.filter.assignedToMe ? query.viewerId : undefined,
watcherId: query.filter.watching ? query.viewerId : undefined,
statuses: query.filter.statuses,
categories: query.filter.categories,
categories: filter.overdue
? (filter.categories?.length ? filter.categories : open).filter((c) =>
open.includes(c),
)
: query.filter.categories,
search: query.filter.search?.trim().slice(0, 200) || undefined,
limit: Math.min(query.filter.limit, 200),
offset: query.filter.offset,

View File

@@ -1,5 +1,6 @@
import { Query } from '@nestjs/cqrs';
import {
IssuePriority,
IssueReadModel,
StatusCategory,
} from '@task-stream/api/tracker/domain';
@@ -15,6 +16,13 @@ export interface MyIssuesFilter {
categories?: StatusCategory[];
/** Matches key, title or description. */
search?: string;
/** Only these of my projects. */
projectIds?: string[];
priorities?: IssuePriority[];
/** Only tickets of a type drawn with the bug icon (in each project). */
bugsOnly?: boolean;
/** Only open tickets past their due date. */
overdue?: boolean;
limit: number;
offset: number;
}

View File

@@ -2,9 +2,16 @@ import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import {
MyViewCriteriaReadModel,
MyViewReadModel,
SavedFilterCriteriaReadModel,
SavedFilterReadModel,
} from '@task-stream/api/tracker/domain';
import {
DeleteMyViewCommand,
GetMyViewsQuery,
SaveMyViewCommand,
} from './saved-filters/my-views';
import {
DeleteSavedFilterCommand,
GetSavedFiltersQuery,
@@ -18,7 +25,7 @@ export interface SaveFilterInput {
criteria: SavedFilterCriteriaReadModel;
}
/** Saved board views: mine and the ones shared with the project. */
/** Saved views: the board's (mine and shared) and my own of «my work». */
@Injectable()
export class SavedFiltersService {
constructor(
@@ -47,4 +54,22 @@ export class SavedFiltersService {
new DeleteSavedFilterCommand(actorId, filterId),
);
}
/** My views of «my work», by name. */
myViews(ownerId: string): Promise<MyViewReadModel[]> {
return this.queryBus.execute(new GetMyViewsQuery(ownerId));
}
saveMyView(
ownerId: string,
input: { name: string; criteria: MyViewCriteriaReadModel },
): Promise<{ id: string }> {
return this.commands.execute(
new SaveMyViewCommand(ownerId, input.name, input.criteria),
);
}
deleteMyView(ownerId: string, viewId: string): Promise<void> {
return this.commands.execute(new DeleteMyViewCommand(ownerId, viewId));
}
}

View File

@@ -0,0 +1,133 @@
import { randomUUID } from 'node:crypto';
import {
Command,
CommandHandler,
ICommandHandler,
IQueryHandler,
Query,
QueryHandler,
} from '@nestjs/cqrs';
import {
BusinessRuleError,
NotFoundError,
assertThat,
} from '@task-stream/api/shared/domain';
import {
MyViewCriteriaReadModel,
MyViewReadModel,
} from '@task-stream/api/tracker/domain';
import { MyViewsRepository } from '../ports/my-views.repository';
/*
* Saved views of «my work»: across my projects, private, no history (like
* the board's saved views and notification settings).
*/
/** A person keeps at most this many. */
export const MAX_MY_VIEWS = 20;
export class SaveMyViewCommand extends Command<{ id: string }> {
constructor(
public readonly ownerId: string,
public readonly name: string,
public readonly criteria: MyViewCriteriaReadModel,
) {
super();
}
}
export class DeleteMyViewCommand extends Command<void> {
constructor(
public readonly ownerId: string,
public readonly viewId: string,
) {
super();
}
}
export class GetMyViewsQuery extends Query<MyViewReadModel[]> {
constructor(public readonly ownerId: string) {
super();
}
}
/** Drops empty criteria, trims the text and deduplicates lists. */
export function normalizeMyView(
criteria: MyViewCriteriaReadModel,
): MyViewCriteriaReadModel {
const list = <T extends string>(values?: T[] | null) =>
values?.length ? [...new Set(values)] : undefined;
const out: MyViewCriteriaReadModel = {
projectIds: list(criteria.projectIds),
assignedToMe: criteria.assignedToMe || undefined,
watching: criteria.watching || undefined,
categories: list(criteria.categories),
priorities: list(criteria.priorities),
bugsOnly: criteria.bugsOnly || undefined,
overdue: criteria.overdue || undefined,
search: criteria.search?.trim().slice(0, 200) || undefined,
};
return Object.fromEntries(
Object.entries(out).filter(([, value]) => value !== undefined),
) as MyViewCriteriaReadModel;
}
@CommandHandler(SaveMyViewCommand)
export class SaveMyViewCommandHandler
implements ICommandHandler<SaveMyViewCommand>
{
constructor(private readonly views: MyViewsRepository) {}
async execute(command: SaveMyViewCommand): Promise<{ id: string }> {
const name = command.name.trim();
assertThat(
name.length > 0 && name.length <= 60,
'A view needs a name (up to 60 characters)',
);
if ((await this.views.countByOwner(command.ownerId)) >= MAX_MY_VIEWS)
throw new BusinessRuleError(
`You already keep ${MAX_MY_VIEWS} views; delete one first`,
);
const id = randomUUID();
await this.views.insert({
id,
ownerId: command.ownerId,
name,
criteria: normalizeMyView(command.criteria),
createdAt: new Date().toISOString(),
});
return { id };
}
}
@CommandHandler(DeleteMyViewCommand)
export class DeleteMyViewCommandHandler
implements ICommandHandler<DeleteMyViewCommand>
{
constructor(private readonly views: MyViewsRepository) {}
async execute(command: DeleteMyViewCommand): Promise<void> {
const view = await this.views.findById(command.viewId);
// Someone else's view does not exist, as far as this person knows.
if (!view || view.ownerId !== command.ownerId)
throw new NotFoundError('View', command.viewId);
await this.views.delete(view.id);
}
}
@QueryHandler(GetMyViewsQuery)
export class GetMyViewsQueryHandler
implements IQueryHandler<GetMyViewsQuery, MyViewReadModel[]>
{
constructor(private readonly views: MyViewsRepository) {}
execute(query: GetMyViewsQuery): Promise<MyViewReadModel[]> {
return this.views.findByOwner(query.ownerId);
}
}
export const MY_VIEWS = [
SaveMyViewCommandHandler,
DeleteMyViewCommandHandler,
GetMyViewsQueryHandler,
];

View File

@@ -34,6 +34,7 @@ import {
} from './notifications/notifications.commands';
import { SAVED_FILTERS } from './saved-filters/saved-filters';
import { MY_VIEWS } from './saved-filters/my-views';
import { SendDueRemindersCommandHandler } from './notifications/reminders';
import { SavedFiltersService } from './saved-filters.service';
import { SprintsService } from './sprints.service';
@@ -101,6 +102,7 @@ const SERVICES = [
...SAGAS,
...NOTIFICATIONS,
...SAVED_FILTERS,
...MY_VIEWS,
...GIT_HANDLERS,
...WEBHOOK_HANDLERS,
...TRANSFER_HANDLERS,