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

@@ -1441,4 +1441,74 @@ describe('task-stream (e2e)', () => {
expect(later.body['quality'].bugWeeks).toHaveLength(8);
expect(later.body['fields']).toEqual([]);
});
it('keeps my own views of my work, across my projects', async () => {
const key = 'V' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
const project = await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
const bug = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'BUG',
title: 'Late bug',
priority: 'CRITICAL',
dueDate: '2020-01-01',
})
).body['key'];
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Late task',
priority: 'CRITICAL',
dueDate: '2020-01-01',
});
await eventually(
() => rest('token-ana', 'GET', `/issues/${key}-2`),
(r) => r.status === 200,
);
const criteria = {
projectIds: [project.body['id']],
priorities: ['CRITICAL'],
bugsOnly: true,
overdue: true,
};
const saved = await rest('token-ana', 'POST', '/me/views', {
name: ' Bugs críticos vencidos ',
criteria,
});
expect(saved.status).toBe(201);
const views = await rest('token-ana', 'GET', '/me/views');
expect(views.body).toEqual([
expect.objectContaining({ name: 'Bugs críticos vencidos', criteria }),
]);
expect((await rest('token-bob', 'GET', '/me/views')).body).toEqual([]);
// Running it: the same criteria on my work.
const page = await rest(
'token-ana',
'GET',
`/me/issues?assignedToMe=false&project=${project.body['id']}&priority=CRITICAL&bugsOnly=true&overdue=true`,
);
expect(page.body['items'].map((i: { key: string }) => i.key)).toEqual([
bug,
]);
const { myIssues } = await gql(
'token-ana',
`query($p: [ID!]) { myIssues(assignedToMe: false, projectIds: $p, overdue: true) { total } }`,
{ p: [project.body['id']] },
);
expect(myIssues.total).toBe(2);
// Only its owner deletes it.
const id = saved.body['id'];
expect((await rest('token-bob', 'DELETE', `/me/views/${id}`)).status).toBe(
404,
);
expect((await rest('token-ana', 'DELETE', `/me/views/${id}`)).status).toBe(
204,
);
expect((await rest('token-ana', 'GET', '/me/views')).body).toEqual([]);
});
});

View File

@@ -2260,6 +2260,47 @@
"schema": {
"type": "string"
}
},
{
"name": "project",
"required": false,
"in": "query",
"description": "Only these of my projects (ids). Repeat or separate with commas",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"name": "priority",
"required": false,
"in": "query",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/IssuePriority"
}
}
},
{
"name": "bugsOnly",
"required": false,
"in": "query",
"description": "Only types with the bug icon",
"schema": {
"type": "boolean"
}
},
{
"name": "overdue",
"required": false,
"in": "query",
"description": "Only open tickets past their due date",
"schema": {
"type": "boolean"
}
}
],
"responses": {
@@ -7806,6 +7847,271 @@
]
}
},
"/api/me/views": {
"get": {
"operationId": "SavedFiltersController_myViews",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MyViewDto"
}
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "My saved views of my work, across my projects (by name)",
"tags": [
"Saved filters"
]
},
"post": {
"description": "Run it with GET /api/me/issues and the same criteria (project, priority, bugsOnly, overdue…).",
"operationId": "SavedFiltersController_saveMyView",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SaveMyViewDto"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SavedFilterIdDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Save a view of my work under a name",
"tags": [
"Saved filters"
]
}
},
"/api/me/views/{id}": {
"delete": {
"operationId": "SavedFiltersController_deleteMyView",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Delete one of my views",
"tags": [
"Saved filters"
]
}
},
"/api/projects/{project}/sprints": {
"get": {
"description": "The one under way, the planned ones, then the closed ones.",
@@ -14193,6 +14499,132 @@
"id"
]
},
"MyViewCriteriaResponseDto": {
"type": "object",
"properties": {
"projectIds": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
}
},
"assignedToMe": {
"type": "boolean"
},
"watching": {
"type": "boolean"
},
"categories": {
"type": "array",
"items": {
"$ref": "#/components/schemas/StatusCategory"
}
},
"priorities": {
"type": "array",
"items": {
"$ref": "#/components/schemas/IssuePriority"
}
},
"bugsOnly": {
"type": "boolean"
},
"overdue": {
"type": "boolean"
},
"search": {
"type": "string"
}
}
},
"MyViewDto": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string",
"example": "Bugs críticos de todos mis proyectos"
},
"criteria": {
"$ref": "#/components/schemas/MyViewCriteriaResponseDto"
},
"createdAt": {
"type": "string",
"format": "date-time"
}
},
"required": [
"id",
"name",
"criteria",
"createdAt"
]
},
"MyViewCriteriaDto": {
"type": "object",
"properties": {
"projectIds": {
"description": "Only these of my projects (empty: all)",
"type": "array",
"items": {
"type": "string",
"format": "uuid"
}
},
"assignedToMe": {
"type": "boolean"
},
"watching": {
"type": "boolean",
"description": "Only the tickets I follow"
},
"categories": {
"type": "array",
"items": {
"$ref": "#/components/schemas/StatusCategory"
}
},
"priorities": {
"type": "array",
"items": {
"$ref": "#/components/schemas/IssuePriority"
}
},
"bugsOnly": {
"type": "boolean",
"description": "Only types with the bug icon"
},
"overdue": {
"type": "boolean",
"description": "Only open tickets past due"
},
"search": {
"type": "string",
"maxLength": 200
}
}
},
"SaveMyViewDto": {
"type": "object",
"properties": {
"name": {
"type": "string",
"example": "Bugs críticos de todos mis proyectos",
"maxLength": 60
},
"criteria": {
"$ref": "#/components/schemas/MyViewCriteriaDto"
}
},
"required": [
"name",
"criteria"
]
},
"SprintStatus": {
"type": "string",
"enum": [

View File

@@ -836,6 +836,9 @@ type Mutation {
"""Author or project admin"""
deleteComment(commentId: ID!): CommandResult!
"""Delete one of my views"""
deleteMyView(id: ID!): Boolean!
"""
Delete the project (owner only), with its tickets, comments and releases. `confirmKey` must be the project key.
"""
@@ -893,6 +896,9 @@ type Mutation {
"""Save the current board filters under a name (members of the project)"""
saveFilter(input: SaveFilterInput!): ID!
"""Save a view of my work under a name"""
saveMyView(input: SaveMyViewInput!): ID!
"""
Place a ticket under an epic or another ticket of the same project (null: back to the top). Three levels at most: epic → ticket → subtask.
"""
@@ -965,6 +971,45 @@ type MyNotifications {
unread: Int!
}
"""A named view of my work (private)"""
type MyView {
createdAt: String!
criteria: MyViewCriteria!
id: ID!
name: String!
}
"""What a view of my work narrows to; empty: every ticket of my projects"""
type MyViewCriteria {
assignedToMe: Boolean
"""Only types with the bug icon"""
bugsOnly: Boolean
categories: [StatusCategory!]
"""Only open tickets past due"""
overdue: Boolean
priorities: [IssuePriority!]
"""Empty: all my projects"""
projectIds: [ID!]
search: String
watching: Boolean
}
input MyViewCriteriaInput {
assignedToMe: Boolean
bugsOnly: Boolean
categories: [StatusCategory!]
overdue: Boolean
priorities: [IssuePriority!]
"""Empty: all my projects"""
projectIds: [ID!]
search: String
watching: Boolean
}
type Notification {
actor: UserRef!
createdAt: String!
@@ -1234,10 +1279,20 @@ type Query {
"""
myIssues(
assignedToMe: Boolean! = true
"""Only types drawn with the bug icon"""
bugsOnly: Boolean
categories: [StatusCategory!]
limit: Int! = 50
offset: Int! = 0
"""Only open tickets past their due date"""
overdue: Boolean
priorities: [IssuePriority!]
"""Only these of my projects"""
projectIds: [ID!]
"""Matches key, title or description (global search)"""
search: String
statuses: [String!]
@@ -1251,6 +1306,9 @@ type Query {
"""Projects where I am a member"""
myProjects: [Project!]!
"""My saved views of my work, across my projects"""
myViews: [MyView!]!
notificationSettings: NotificationSettings!
"""A project of mine, or any public one (no sign-in needed)"""
@@ -1399,6 +1457,11 @@ input SaveFilterInput {
shared: Boolean! = false
}
input SaveMyViewInput {
criteria: MyViewCriteriaInput!
name: String!
}
"""
A named view of the board: private to whoever saved it, or shared with the project
"""

View File

@@ -1188,6 +1188,44 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/me/views": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** My saved views of my work, across my projects (by name) */
get: operations["SavedFiltersController_myViews"];
put?: never;
/**
* Save a view of my work under a name
* @description Run it with GET /api/me/issues and the same criteria (project, priority, bugsOnly, overdue…).
*/
post: operations["SavedFiltersController_saveMyView"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/me/views/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Delete one of my views */
delete: operations["SavedFiltersController_deleteMyView"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/projects/{project}/sprints": {
parameters: {
query?: never;
@@ -2879,6 +2917,44 @@ export interface components {
/** Format: uuid */
id: string;
};
MyViewCriteriaResponseDto: {
projectIds?: string[];
assignedToMe?: boolean;
watching?: boolean;
categories?: components["schemas"]["StatusCategory"][];
priorities?: components["schemas"]["IssuePriority"][];
bugsOnly?: boolean;
overdue?: boolean;
search?: string;
};
MyViewDto: {
/** Format: uuid */
id: string;
/** @example Bugs críticos de todos mis proyectos */
name: string;
criteria: components["schemas"]["MyViewCriteriaResponseDto"];
/** Format: date-time */
createdAt: string;
};
MyViewCriteriaDto: {
/** @description Only these of my projects (empty: all) */
projectIds?: string[];
assignedToMe?: boolean;
/** @description Only the tickets I follow */
watching?: boolean;
categories?: components["schemas"]["StatusCategory"][];
priorities?: components["schemas"]["IssuePriority"][];
/** @description Only types with the bug icon */
bugsOnly?: boolean;
/** @description Only open tickets past due */
overdue?: boolean;
search?: string;
};
SaveMyViewDto: {
/** @example Bugs críticos de todos mis proyectos */
name: string;
criteria: components["schemas"]["MyViewCriteriaDto"];
};
/** @enum {string} */
SprintStatus: "PLANNED" | "ACTIVE" | "CLOSED";
SprintDto: {
@@ -4600,6 +4676,13 @@ export interface operations {
category?: components["schemas"]["StatusCategory"][];
/** @description Matches key, title or description */
search?: string;
/** @description Only these of my projects (ids). Repeat or separate with commas */
project?: string[];
priority?: components["schemas"]["IssuePriority"][];
/** @description Only types with the bug icon */
bugsOnly?: boolean;
/** @description Only open tickets past their due date */
overdue?: boolean;
};
header?: never;
path?: never;
@@ -8458,6 +8541,211 @@ export interface operations {
};
};
};
SavedFiltersController_myViews: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MyViewDto"][];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
SavedFiltersController_saveMyView: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SaveMyViewDto"];
};
};
responses: {
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SavedFilterIdDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
SavedFiltersController_deleteMyView: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
SprintsController_list: {
parameters: {
query?: never;

View File

@@ -88,6 +88,15 @@ export class IssuesResolver {
.array(CATEGORY)
.optional()
.describe('Default: open ones (TODO, IN_PROGRESS, QA)'),
priorities: z.array(PRIORITY).optional(),
bugsOnly: z
.boolean()
.optional()
.describe('Only types drawn with the bug icon, in each project'),
overdue: z
.boolean()
.optional()
.describe('Only open tickets past their due date'),
limit: limit(200, 50),
}),
annotations: READ,
@@ -97,6 +106,9 @@ export class IssuesResolver {
assignedToMe: boolean;
watching: boolean;
categories?: z.infer<typeof CATEGORY>[];
priorities?: z.infer<typeof PRIORITY>[];
bugsOnly?: boolean;
overdue?: boolean;
limit: number;
},
ctx: McpContext,
@@ -109,6 +121,9 @@ export class IssuesResolver {
assignedToMe: params.assignedToMe,
watching: params.watching,
category: params.categories ?? ['TODO', 'IN_PROGRESS', 'QA'],
priority: params.priorities,
bugsOnly: params.bugsOnly,
overdue: params.overdue,
limit: params.limit,
},
},