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['quality'].bugWeeks).toHaveLength(8);
expect(later.body['fields']).toEqual([]); 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": { "schema": {
"type": "string" "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": { "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": { "/api/projects/{project}/sprints": {
"get": { "get": {
"description": "The one under way, the planned ones, then the closed ones.", "description": "The one under way, the planned ones, then the closed ones.",
@@ -14193,6 +14499,132 @@
"id" "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": { "SprintStatus": {
"type": "string", "type": "string",
"enum": [ "enum": [

View File

@@ -836,6 +836,9 @@ type Mutation {
"""Author or project admin""" """Author or project admin"""
deleteComment(commentId: ID!): CommandResult! 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. 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)""" """Save the current board filters under a name (members of the project)"""
saveFilter(input: SaveFilterInput!): ID! 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. 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! 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 { type Notification {
actor: UserRef! actor: UserRef!
createdAt: String! createdAt: String!
@@ -1234,10 +1279,20 @@ type Query {
""" """
myIssues( myIssues(
assignedToMe: Boolean! = true assignedToMe: Boolean! = true
"""Only types drawn with the bug icon"""
bugsOnly: Boolean
categories: [StatusCategory!] categories: [StatusCategory!]
limit: Int! = 50 limit: Int! = 50
offset: Int! = 0 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)""" """Matches key, title or description (global search)"""
search: String search: String
statuses: [String!] statuses: [String!]
@@ -1251,6 +1306,9 @@ type Query {
"""Projects where I am a member""" """Projects where I am a member"""
myProjects: [Project!]! myProjects: [Project!]!
"""My saved views of my work, across my projects"""
myViews: [MyView!]!
notificationSettings: NotificationSettings! notificationSettings: NotificationSettings!
"""A project of mine, or any public one (no sign-in needed)""" """A project of mine, or any public one (no sign-in needed)"""
@@ -1399,6 +1457,11 @@ input SaveFilterInput {
shared: Boolean! = false 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 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; patch?: never;
trace?: 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": { "/api/projects/{project}/sprints": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2879,6 +2917,44 @@ export interface components {
/** Format: uuid */ /** Format: uuid */
id: string; 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} */ /** @enum {string} */
SprintStatus: "PLANNED" | "ACTIVE" | "CLOSED"; SprintStatus: "PLANNED" | "ACTIVE" | "CLOSED";
SprintDto: { SprintDto: {
@@ -4600,6 +4676,13 @@ export interface operations {
category?: components["schemas"]["StatusCategory"][]; category?: components["schemas"]["StatusCategory"][];
/** @description Matches key, title or description */ /** @description Matches key, title or description */
search?: string; 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; header?: never;
path?: 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: { SprintsController_list: {
parameters: { parameters: {
query?: never; query?: never;

View File

@@ -88,6 +88,15 @@ export class IssuesResolver {
.array(CATEGORY) .array(CATEGORY)
.optional() .optional()
.describe('Default: open ones (TODO, IN_PROGRESS, QA)'), .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), limit: limit(200, 50),
}), }),
annotations: READ, annotations: READ,
@@ -97,6 +106,9 @@ export class IssuesResolver {
assignedToMe: boolean; assignedToMe: boolean;
watching: boolean; watching: boolean;
categories?: z.infer<typeof CATEGORY>[]; categories?: z.infer<typeof CATEGORY>[];
priorities?: z.infer<typeof PRIORITY>[];
bugsOnly?: boolean;
overdue?: boolean;
limit: number; limit: number;
}, },
ctx: McpContext, ctx: McpContext,
@@ -109,6 +121,9 @@ export class IssuesResolver {
assignedToMe: params.assignedToMe, assignedToMe: params.assignedToMe,
watching: params.watching, watching: params.watching,
category: params.categories ?? ['TODO', 'IN_PROGRESS', 'QA'], category: params.categories ?? ['TODO', 'IN_PROGRESS', 'QA'],
priority: params.priorities,
bugsOnly: params.bugsOnly,
overdue: params.overdue,
limit: params.limit, limit: params.limit,
}, },
}, },

View File

@@ -435,7 +435,7 @@ Todas devuelven `CommandResult { id, key, version }`.
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` | | Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` | | Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` | | Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)` | | Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)`, `GET/POST /api/me/views`, `DELETE /api/me/views/{id}` (mis vistas de «Mi trabajo») |
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` | | Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` | | Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` |
@@ -635,10 +635,10 @@ Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`). Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
| Recurso | Rutas | | Recurso | Rutas |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` | | Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) | | Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` | | Tickets | `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions` | | Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions` |
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` | | Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` | | Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |

View File

@@ -114,6 +114,7 @@ Qué gana la página:
| `AuthFacade` | `AuthStore` | raíz | Sesión, usuario, login (PKCE o email y contraseña de Pireon), renovación del token, restaurar al arrancar | | `AuthFacade` | `AuthStore` | raíz | Sesión, usuario, login (PKCE o email y contraseña de Pireon), renovación del token, restaurar al arrancar |
| `ProjectsFacade` | `ProjectsStore` | raíz | Mis proyectos; se actualiza cuando me añaden o quitan (`myProjectsChanged`) | | `ProjectsFacade` | `ProjectsStore` | raíz | Mis proyectos; se actualiza cuando me añaden o quitan (`myProjectsChanged`) |
| `HomeFacade` | `HomeStore` | raíz | «Mi trabajo» en la portada: mis tickets abiertos por categoría (QA fallidos primero), esperando QA y próximas releases; se recarga al volver a la pestaña | | `HomeFacade` | `HomeStore` | raíz | «Mi trabajo» en la portada: mis tickets abiertos por categoría (QA fallidos primero), esperando QA y próximas releases; se recarga al volver a la pestaña |
| `MyViewsFacade` | `MyViewsStore` | raíz | Mis vistas guardadas de «Mi trabajo» (entre proyectos: proyectos, asignados a mí, que sigo, estados, prioridades, solo bugs, vencidos, texto) y los tickets de la elegida |
| `ExploreFacade` | `ExploreStore` | raíz | Proyectos públicos («Explorar»), también sin sesión | | `ExploreFacade` | `ExploreStore` | raíz | Proyectos públicos («Explorar»), también sin sesión |
| `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo, búsqueda de usuarios, configuración (`workflow`, `saveSettings`) y estadísticas (`loadAnalytics`, `loadBurnup`) | | `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo, búsqueda de usuarios, configuración (`workflow`, `saveSettings`) y estadísticas (`loadAnalytics`, `loadBurnup`) |
| `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista, nuevo ticket, cambios en vivo | | `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista, nuevo ticket, cambios en vivo |
@@ -284,7 +285,7 @@ visualización de la plataforma:
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `/login` | Inicio de sesión (solo sin sesión) | | `/login` | Inicio de sesión (solo sin sesión) |
| `/auth/callback` | Retorno de Pireon (PKCE) | | `/auth/callback` | Retorno de Pireon (PKCE) |
| `/` | Inicio: «Ir a…» (`WEB-12`), mi trabajo (`ts-my-work`) y mis proyectos | | `/` | Inicio: «Ir a…» (`WEB-12`), mis vistas guardadas (`ts-my-views`: una barra para elegir, sus tickets y «+ Nueva vista»), mi trabajo (`ts-my-work`) y mis proyectos |
| `/explorar` | Proyectos públicos (sin sesión) | | `/explorar` | Proyectos públicos (sin sesión) |
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) | | `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
| `/p/:key/issues/:issueKey` | Ticket | | `/p/:key/issues/:issueKey` | Ticket |

View File

@@ -50,7 +50,7 @@ flowchart LR
| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) | | | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) |
| | `get_project_insights` | El panel: sprint en curso, riesgo, antigüedad, cuellos de botella, previsión de releases, carga del equipo, salud del backlog, calidad, abiertos por campo de lista y, con `since`, los cambios | | | `get_project_insights` | El panel: sprint en curso, riesgo, antigüedad, cuellos de botella, previsión de releases, carga del equipo, salud del backlog, calidad, abiertos por campo de lista y, con `since`, los cambios |
| | `get_project_activity` | Actividad reciente | | | `get_project_activity` | Actividad reciente |
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo) | | Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo); también por prioridad, solo bugs o vencidos |
| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` | | | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` |
| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios | | | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios |
| | `create_issue` | Crear un ticket (también colgado de una épica) | | | `create_issue` | Crear un ticket (también colgado de una épica) |

View File

@@ -35,8 +35,6 @@ Nada: la tanda está hecha (ver 10.5).
### Aceptadas, para después de esta tanda ### Aceptadas, para después de esta tanda
- **Sincronizar con issues de Gitea o GitHub**: que un issue del repositorio cree o actualice un ticket, y al revés. - **Sincronizar con issues de Gitea o GitHub**: que un issue del repositorio cree o actualice un ticket, y al revés.
- **Vistas guardadas en «Mi trabajo»**: filtros guardados de varios proyectos («Bugs críticos de todos mis
proyectos»).
- **Búsqueda con lenguaje de consulta**: `estado:abierto asignado:yo navegador:Safari prioridad>=alta`, guardable como - **Búsqueda con lenguaje de consulta**: `estado:abierto asignado:yo navegador:Safari prioridad>=alta`, guardable como
vista. vista.
- **Definición de «Hecho»**: no pasar a un estado `DONE` con la lista de comprobación a medias (salvo un admin). - **Definición de «Hecho»**: no pasar a un estado `DONE` con la lista de comprobación a medias (salvo un admin).
@@ -119,3 +117,5 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
bugs tras cada release) y abiertos por opción de un campo de lista. bugs tras cada release) y abiertos por opción de un campo de lista.
- Escrituras a la vez en un ticket: la API repite sola la orden que choca con otra (por ejemplo, con una saga), en - Escrituras a la vez en un ticket: la API repite sola la orden que choca con otra (por ejemplo, con una saga), en
lugar de devolver un 409. lugar de devolver un 409.
- Vistas guardadas en «Mi trabajo»: filtros propios entre proyectos (proyectos, asignados a mí, que sigo, estados,
prioridades, solo bugs, vencidos, texto), en una barra encima de «Mi trabajo».

Binary file not shown.

Binary file not shown.

View File

@@ -29,4 +29,6 @@ export * from './lib/webhooks/webhook-events';
export * from './lib/git/git'; export * from './lib/git/git';
export * from './lib/git/git-payloads'; export * from './lib/git/git-payloads';
export * from './lib/saved-filters/saved-filters'; 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'; export * from './lib/tracker-application.module';

View File

@@ -60,6 +60,11 @@ export interface CrossProjectIssueFilter {
watcherId?: string; watcherId?: string;
statuses?: string[]; statuses?: string[];
categories?: StatusCategory[]; 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; search?: string;
limit: number; limit: number;
offset: 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 { 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 { FindIssuesRepository, Page } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository'; import { FindProjectsRepository } from '../ports/find-projects.repository';
import { SearchMyIssuesQuery } from './search-my-issues.query'; 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. */ /** Membership is the access rule: only projects the viewer belongs to. */
async execute(query: SearchMyIssuesQuery): Promise<Page<IssueReadModel>> { 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({ return this.findIssues.searchAcross({
projectIds: projects.map((project) => project.id), 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, assigneeId: query.filter.assignedToMe ? query.viewerId : undefined,
watcherId: query.filter.watching ? query.viewerId : undefined, watcherId: query.filter.watching ? query.viewerId : undefined,
statuses: query.filter.statuses, 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, search: query.filter.search?.trim().slice(0, 200) || undefined,
limit: Math.min(query.filter.limit, 200), limit: Math.min(query.filter.limit, 200),
offset: query.filter.offset, offset: query.filter.offset,

View File

@@ -1,5 +1,6 @@
import { Query } from '@nestjs/cqrs'; import { Query } from '@nestjs/cqrs';
import { import {
IssuePriority,
IssueReadModel, IssueReadModel,
StatusCategory, StatusCategory,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
@@ -15,6 +16,13 @@ export interface MyIssuesFilter {
categories?: StatusCategory[]; categories?: StatusCategory[];
/** Matches key, title or description. */ /** Matches key, title or description. */
search?: string; 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; limit: number;
offset: number; offset: number;
} }

View File

@@ -2,9 +2,16 @@ import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs'; import { QueryBus } from '@nestjs/cqrs';
import { import {
MyViewCriteriaReadModel,
MyViewReadModel,
SavedFilterCriteriaReadModel, SavedFilterCriteriaReadModel,
SavedFilterReadModel, SavedFilterReadModel,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
import {
DeleteMyViewCommand,
GetMyViewsQuery,
SaveMyViewCommand,
} from './saved-filters/my-views';
import { import {
DeleteSavedFilterCommand, DeleteSavedFilterCommand,
GetSavedFiltersQuery, GetSavedFiltersQuery,
@@ -18,7 +25,7 @@ export interface SaveFilterInput {
criteria: SavedFilterCriteriaReadModel; 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() @Injectable()
export class SavedFiltersService { export class SavedFiltersService {
constructor( constructor(
@@ -47,4 +54,22 @@ export class SavedFiltersService {
new DeleteSavedFilterCommand(actorId, filterId), 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'; } from './notifications/notifications.commands';
import { SAVED_FILTERS } from './saved-filters/saved-filters'; import { SAVED_FILTERS } from './saved-filters/saved-filters';
import { MY_VIEWS } from './saved-filters/my-views';
import { SendDueRemindersCommandHandler } from './notifications/reminders'; import { SendDueRemindersCommandHandler } from './notifications/reminders';
import { SavedFiltersService } from './saved-filters.service'; import { SavedFiltersService } from './saved-filters.service';
import { SprintsService } from './sprints.service'; import { SprintsService } from './sprints.service';
@@ -101,6 +102,7 @@ const SERVICES = [
...SAGAS, ...SAGAS,
...NOTIFICATIONS, ...NOTIFICATIONS,
...SAVED_FILTERS, ...SAVED_FILTERS,
...MY_VIEWS,
...GIT_HANDLERS, ...GIT_HANDLERS,
...WEBHOOK_HANDLERS, ...WEBHOOK_HANDLERS,
...TRANSFER_HANDLERS, ...TRANSFER_HANDLERS,

View File

@@ -1,4 +1,5 @@
import { IssuePriority } from '../issue/issue.enums'; import { IssuePriority } from '../issue/issue.enums';
import { StatusCategory } from '../project/project-settings';
import { UserRefReadModel } from './user-ref.read-model'; import { UserRefReadModel } from './user-ref.read-model';
/** What a saved view of the board narrows the tickets to. Empty: everything. */ /** What a saved view of the board narrows the tickets to. Empty: everything. */
@@ -27,3 +28,31 @@ export class SavedFilterReadModel {
criteria!: SavedFilterCriteriaReadModel; criteria!: SavedFilterCriteriaReadModel;
createdAt!: string; createdAt!: string;
} }
/**
* What a view of «my work» narrows to, across the projects I belong to.
* Empty: every ticket of them.
*/
export class MyViewCriteriaReadModel {
/** Only these projects (empty: all of mine). */
projectIds?: string[];
assignedToMe?: boolean;
/** Only the tickets I follow. */
watching?: boolean;
categories?: StatusCategory[];
priorities?: IssuePriority[];
/** Only tickets of a type drawn with the bug icon. */
bugsOnly?: boolean;
/** Only open tickets past their due date. */
overdue?: boolean;
search?: string;
}
/** A named view of «my work», private to whoever saved it. */
export class MyViewReadModel {
id!: string;
ownerId!: string;
name!: string;
criteria!: MyViewCriteriaReadModel;
createdAt!: string;
}

View File

@@ -172,14 +172,27 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
if (filter.statuses?.length) query['status'] = { $in: filter.statuses }; if (filter.statuses?.length) query['status'] = { $in: filter.statuses };
if (filter.categories?.length) if (filter.categories?.length)
query['statusCategory'] = { $in: filter.categories }; query['statusCategory'] = { $in: filter.categories };
if (filter.priorities?.length)
query['priority'] = { $in: filter.priorities };
if (filter.dueBefore)
query['dueDate'] = { $ne: null, $lt: filter.dueBefore };
const and: Record<string, unknown>[] = [];
if (filter.typesByProject)
and.push({
$or: Object.entries(filter.typesByProject).map(
([projectId, types]) => ({
projectId,
type: { $in: types },
}),
),
});
if (filter.search?.trim()) { if (filter.search?.trim()) {
const pattern = new RegExp(escapeRegex(filter.search.trim()), 'i'); const pattern = new RegExp(escapeRegex(filter.search.trim()), 'i');
query['$or'] = [ and.push({
{ key: pattern }, $or: [{ key: pattern }, { title: pattern }, { description: pattern }],
{ title: pattern }, });
{ description: pattern },
];
} }
if (and.length) query['$and'] = and;
const [docs, total] = await Promise.all([ const [docs, total] = await Promise.all([
this.issues this.issues
.find(query) .find(query)

View File

@@ -0,0 +1,47 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { MyViewsRepository } from '@task-stream/api/tracker/application';
import { MyViewReadModel } from '@task-stream/api/tracker/domain';
import { Model } from 'mongoose';
import { MyViewDoc, ReadModels, fromDoc } from './read-model.schemas';
@Injectable()
export class MongoMyViewsRepository extends MyViewsRepository {
constructor(
@InjectModel(ReadModels.MyView)
private readonly views: Model<MyViewDoc>,
) {
super();
}
async findByOwner(ownerId: string): Promise<MyViewReadModel[]> {
const docs = await this.views
.find({ ownerId })
.sort({ name: 1 })
.lean<MyViewDoc[]>();
return docs.map(toView);
}
async findById(id: string): Promise<MyViewReadModel | null> {
const doc = await this.views.findOne({ id }).lean<MyViewDoc>();
return doc ? toView(doc) : null;
}
countByOwner(ownerId: string): Promise<number> {
return this.views.countDocuments({ ownerId });
}
async insert(view: MyViewReadModel): Promise<void> {
await this.views.create(view);
}
async delete(id: string): Promise<void> {
await this.views.deleteOne({ id });
}
}
/** Mongo leaves empty objects out: a view with no criteria has none. */
function toView(doc: MyViewDoc): MyViewReadModel {
const view = fromDoc(doc) as MyViewReadModel;
return { ...view, criteria: view.criteria ?? {} };
}

View File

@@ -12,6 +12,7 @@ import {
ProjectReadModel, ProjectReadModel,
ReleaseReadModel, ReleaseReadModel,
SavedFilterReadModel, SavedFilterReadModel,
MyViewReadModel,
SprintReadModel, SprintReadModel,
implicitRank, implicitRank,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
@@ -40,6 +41,7 @@ export type ActivityDoc = Doc<ActivityReadModel>;
export type TimelineDoc = Doc<IssueTimelineEntry>; export type TimelineDoc = Doc<IssueTimelineEntry>;
export type NotificationDoc = Doc<NotificationReadModel>; export type NotificationDoc = Doc<NotificationReadModel>;
export type SavedFilterDoc = Doc<SavedFilterReadModel>; export type SavedFilterDoc = Doc<SavedFilterReadModel>;
export type MyViewDoc = Doc<MyViewReadModel>;
export type OutgoingWebhookDoc = Doc<OutgoingWebhook>; export type OutgoingWebhookDoc = Doc<OutgoingWebhook>;
export type AutomationDoc = Doc<Automation>; export type AutomationDoc = Doc<Automation>;
export interface AutomationRunDoc { export interface AutomationRunDoc {
@@ -94,6 +96,7 @@ export const ReadModels = {
Notification: 'NotificationReadModel', Notification: 'NotificationReadModel',
NotificationSettings: 'NotificationSettings', NotificationSettings: 'NotificationSettings',
SavedFilter: 'SavedFilter', SavedFilter: 'SavedFilter',
MyView: 'MyView',
Sprint: 'SprintReadModel', Sprint: 'SprintReadModel',
GitIntegration: 'GitIntegration', GitIntegration: 'GitIntegration',
OutgoingWebhook: 'OutgoingWebhook', OutgoingWebhook: 'OutgoingWebhook',
@@ -302,6 +305,15 @@ export const SavedFilterSchema =
SavedFilterSchema.index({ projectId: 1, 'owner.id': 1 }); SavedFilterSchema.index({ projectId: 1, 'owner.id': 1 });
SavedFilterSchema.index({ projectId: 1, shared: 1 }); SavedFilterSchema.index({ projectId: 1, shared: 1 });
@Schema({ ...LOOSE, collection: 'my_views' })
class MyViewDocument {
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String, index: true }) ownerId!: string;
@Prop({ type: String }) name!: string;
@Prop({ type: String }) createdAt!: string;
}
export const MyViewSchema = SchemaFactory.createForClass(MyViewDocument);
@Schema({ ...LOOSE, collection: 'git_integrations' }) @Schema({ ...LOOSE, collection: 'git_integrations' })
class GitIntegrationDocument { class GitIntegrationDocument {
/** The project's id. */ /** The project's id. */
@@ -390,6 +402,7 @@ export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.Notification, schema: NotificationSchema }, { name: ReadModels.Notification, schema: NotificationSchema },
{ name: ReadModels.NotificationSettings, schema: NotificationSettingsSchema }, { name: ReadModels.NotificationSettings, schema: NotificationSettingsSchema },
{ name: ReadModels.SavedFilter, schema: SavedFilterSchema }, { name: ReadModels.SavedFilter, schema: SavedFilterSchema },
{ name: ReadModels.MyView, schema: MyViewSchema },
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema }, { name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema }, { name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },
]; ];

View File

@@ -11,6 +11,7 @@ import {
PushSubscriptionsRepository, PushSubscriptionsRepository,
PushSender, PushSender,
SavedFiltersRepository, SavedFiltersRepository,
MyViewsRepository,
GitIntegrationsRepository, GitIntegrationsRepository,
OutgoingWebhooksRepository, OutgoingWebhooksRepository,
AutomationsRepository, AutomationsRepository,
@@ -72,6 +73,7 @@ import { MongoCalendarFeedsRepository } from './mongo/calendar-feeds.repository'
import { MongoPushSubscriptionsRepository } from './mongo/push-subscriptions.repository'; import { MongoPushSubscriptionsRepository } from './mongo/push-subscriptions.repository';
import { WebPushSender } from './push/web-push.sender'; import { WebPushSender } from './push/web-push.sender';
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository'; import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
import { MongoMyViewsRepository } from './mongo/my-views.repository';
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository'; import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository'; import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
import { HttpWebhookSender } from './webhooks/http-webhook.sender'; import { HttpWebhookSender } from './webhooks/http-webhook.sender';
@@ -143,6 +145,7 @@ const PORTS = [
}, },
{ provide: PushSender, useClass: WebPushSender }, { provide: PushSender, useClass: WebPushSender },
{ provide: SavedFiltersRepository, useClass: MongoSavedFiltersRepository }, { provide: SavedFiltersRepository, useClass: MongoSavedFiltersRepository },
{ provide: MyViewsRepository, useClass: MongoMyViewsRepository },
{ {
provide: GitIntegrationsRepository, provide: GitIntegrationsRepository,
useClass: MongoGitIntegrationsRepository, useClass: MongoGitIntegrationsRepository,

View File

@@ -784,3 +784,39 @@ export class PushSubscriptionInput {
@Field() @IsString() @MaxLength(200) p256dh!: string; @Field() @IsString() @MaxLength(200) p256dh!: string;
@Field() @IsString() @MaxLength(100) auth!: 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, WorkflowsService,
} from '@task-stream/api/tracker/application'; } from '@task-stream/api/tracker/application';
import { import {
IssuePriority,
IssueReadModel, IssueReadModel,
StatusCategory, StatusCategory,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
@@ -121,6 +122,26 @@ export class IssueResolver {
description: 'Matches key, title or description (global search)', description: 'Matches key, title or description (global search)',
}) })
search?: string | null, 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, { return this.issuesService.mine(me.id, {
assignedToMe, assignedToMe,
@@ -128,6 +149,10 @@ export class IssueResolver {
statuses: statuses ?? undefined, statuses: statuses ?? undefined,
categories: categories ?? undefined, categories: categories ?? undefined,
search: search ?? undefined, search: search ?? undefined,
projectIds: projectIds ?? undefined,
priorities: priorities ?? undefined,
bugsOnly: bugsOnly ?? undefined,
overdue: overdue ?? undefined,
limit, limit,
offset, 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 { User } from '@task-stream/api/identity/domain';
import { CurrentUser } from '@task-stream/api/identity/presenters'; import { CurrentUser } from '@task-stream/api/identity/presenters';
import { SavedFiltersService } from '@task-stream/api/tracker/application'; import { SavedFiltersService } from '@task-stream/api/tracker/application';
import { SaveFilterInput } from './inputs'; import { SaveFilterInput, SaveMyViewInput } from './inputs';
import { SavedFilterGql } from './types'; import { MyViewGql, SavedFilterGql } from './types';
/** Saved views of a project's board. */ /** Saved views of a project's board. */
@Resolver(() => SavedFilterGql) @Resolver(() => SavedFilterGql)
@@ -41,4 +41,36 @@ export class SavedFilterResolver {
await this.filters.delete(me.id, id); await this.filters.delete(me.id, id);
return true; 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; @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') @ObjectType('IssuePage')
export class IssuePageGql { export class IssuePageGql {
@Field(() => [IssueGql]) items!: IssueGql[]; @Field(() => [IssueGql]) items!: IssueGql[];

View File

@@ -500,6 +500,38 @@ export class MyIssuesQueryDto extends PageQueryDto {
@IsString() @IsString()
@MaxLength(200) @MaxLength(200)
search?: string; search?: string;
@ApiPropertyOptional({
type: [String],
description:
'Only these of my projects (ids). Repeat or separate with commas',
})
@IsOptional()
@Transform(toList)
@IsUUID('all', { each: true })
project?: string[];
@ApiPropertyOptional({
enum: IssuePriority,
enumName: 'IssuePriority',
isArray: true,
})
@IsOptional()
@Transform(toList)
@IsEnum(IssuePriority, { each: true })
priority?: IssuePriority[];
@ApiPropertyOptional({ description: 'Only types with the bug icon' })
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
bugsOnly?: boolean;
@ApiPropertyOptional({ description: 'Only open tickets past their due date' })
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
overdue?: boolean;
} }
export class UpcomingReleasesQueryDto { export class UpcomingReleasesQueryDto {
@@ -898,6 +930,69 @@ export class SavedFilterCriteriaDto {
search?: string | null; search?: string | null;
} }
export class MyViewCriteriaDto {
@ApiPropertyOptional({
type: [String],
format: 'uuid',
description: 'Only these of my projects (empty: all)',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(50)
@IsUUID('all', { each: true })
projectIds?: string[];
@ApiPropertyOptional() @IsOptional() @IsBoolean() assignedToMe?: boolean;
@ApiPropertyOptional({ description: 'Only the tickets I follow' })
@IsOptional()
@IsBoolean()
watching?: boolean;
@ApiPropertyOptional({
enum: StatusCategory,
enumName: 'StatusCategory',
isArray: true,
})
@IsOptional()
@IsEnum(StatusCategory, { each: true })
categories?: StatusCategory[];
@ApiPropertyOptional({
enum: IssuePriority,
enumName: 'IssuePriority',
isArray: true,
})
@IsOptional()
@IsEnum(IssuePriority, { each: true })
priorities?: IssuePriority[];
@ApiPropertyOptional({ description: 'Only types with the bug icon' })
@IsOptional()
@IsBoolean()
bugsOnly?: boolean;
@ApiPropertyOptional({ description: 'Only open tickets past due' })
@IsOptional()
@IsBoolean()
overdue?: boolean;
@ApiPropertyOptional({ maxLength: 200 })
@IsOptional()
@IsString()
@MaxLength(200)
search?: string;
}
export class SaveMyViewDto {
@ApiProperty({
example: 'Bugs críticos de todos mis proyectos',
maxLength: 60,
})
@IsString()
@MinLength(1)
@MaxLength(60)
name!: string;
@ApiProperty({ type: MyViewCriteriaDto })
@ValidateNested()
@Type(() => MyViewCriteriaDto)
criteria!: MyViewCriteriaDto;
}
export class SaveFilterDto { export class SaveFilterDto {
@ApiProperty({ example: 'Bugs críticos de frontend', maxLength: 60 }) @ApiProperty({ example: 'Bugs críticos de frontend', maxLength: 60 })
@IsString() @IsString()

View File

@@ -526,6 +526,37 @@ export class SavedFilterDto {
@ApiProperty({ format: 'date-time' }) createdAt!: string; @ApiProperty({ format: 'date-time' }) createdAt!: string;
} }
export class MyViewCriteriaResponseDto {
@ApiPropertyOptional({ type: [String], format: 'uuid' })
projectIds?: string[];
@ApiPropertyOptional() assignedToMe?: boolean;
@ApiPropertyOptional() watching?: boolean;
@ApiPropertyOptional({
enum: StatusCategory,
enumName: 'StatusCategory',
isArray: true,
})
categories?: StatusCategory[];
@ApiPropertyOptional({
enum: IssuePriority,
enumName: 'IssuePriority',
isArray: true,
})
priorities?: IssuePriority[];
@ApiPropertyOptional() bugsOnly?: boolean;
@ApiPropertyOptional() overdue?: boolean;
@ApiPropertyOptional() search?: string;
}
export class MyViewDto {
@ApiProperty({ format: 'uuid' }) id!: string;
@ApiProperty({ example: 'Bugs críticos de todos mis proyectos' })
name!: string;
@ApiProperty({ type: MyViewCriteriaResponseDto })
criteria!: MyViewCriteriaResponseDto;
@ApiProperty({ format: 'date-time' }) createdAt!: string;
}
export class SavedFilterIdDto { export class SavedFilterIdDto {
@ApiProperty({ format: 'uuid' }) id!: string; @ApiProperty({ format: 'uuid' }) id!: string;
} }

View File

@@ -132,6 +132,10 @@ export class IssuesController {
statuses: query.status, statuses: query.status,
categories: query.category, categories: query.category,
search: query.search, search: query.search,
projectIds: query.project,
priorities: query.priority,
bugsOnly: query.bugsOnly,
overdue: query.overdue,
limit: query.limit, limit: query.limit,
offset: query.offset, offset: query.offset,
}); });

View File

@@ -26,8 +26,12 @@ import {
ApiReadErrors, ApiReadErrors,
} from '@task-stream/api/shared/presenters'; } from '@task-stream/api/shared/presenters';
import { SavedFiltersService } from '@task-stream/api/tracker/application'; import { SavedFiltersService } from '@task-stream/api/tracker/application';
import { SaveFilterDto } from './dto/requests.dto'; import { SaveFilterDto, SaveMyViewDto } from './dto/requests.dto';
import { SavedFilterDto, SavedFilterIdDto } from './dto/responses.dto'; import {
MyViewDto,
SavedFilterDto,
SavedFilterIdDto,
} from './dto/responses.dto';
import { Refs } from './refs'; import { Refs } from './refs';
/** Saved views of a project's board: mine, and the ones shared with it. */ /** Saved views of a project's board: mine, and the ones shared with it. */
@@ -93,4 +97,39 @@ export class SavedFiltersController {
) { ) {
await this.filters.delete(me.id, id); await this.filters.delete(me.id, id);
} }
@Get('me/views')
@ApiOperation({
summary: 'My saved views of my work, across my projects (by name)',
})
@ApiOkResponse({ type: [MyViewDto] })
@ApiReadErrors()
myViews(@CurrentUser() me: User) {
return this.filters.myViews(me.id);
}
@Post('me/views')
@ApiOperation({
summary: '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…).',
})
@ApiCreatedResponse({ type: SavedFilterIdDto })
@ApiCommandErrors()
saveMyView(@CurrentUser() me: User, @Body() body: SaveMyViewDto) {
return this.filters.saveMyView(me.id, body);
}
@Delete('me/views/:id')
@HttpCode(204)
@ApiParam({ name: 'id', format: 'uuid' })
@ApiOperation({ summary: 'Delete one of my views' })
@ApiNoContentResponse()
@ApiCommandErrors()
async deleteMyView(
@CurrentUser() me: User,
@Param('id', ParseUUIDPipe) id: string,
) {
await this.filters.deleteMyView(me.id, id);
}
} }

View File

@@ -10,6 +10,7 @@ export * from './lib/facades/explore.facade';
export * from './lib/facades/inbox.facade'; export * from './lib/facades/inbox.facade';
export * from './lib/facades/epics.facade'; export * from './lib/facades/epics.facade';
export * from './lib/facades/insights.facade'; export * from './lib/facades/insights.facade';
export * from './lib/facades/my-views.facade';
export * from './lib/facades/backlog.facade'; export * from './lib/facades/backlog.facade';
export * from './lib/facades/list.facade'; export * from './lib/facades/list.facade';
export * from './lib/facades/propose.facade'; export * from './lib/facades/propose.facade';

View File

@@ -0,0 +1,32 @@
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';
import {
IssuePage,
MyView,
MyViewCriteria,
} from '@task-stream/web/tracker/domain';
export const myViewsPageEvents = eventGroup({
source: 'My Work Views',
events: {
opened: type<void>(),
/** null: back to «Mis tickets». */
selected: type<string | null>(),
saveRequested: type<{ name: string; criteria: MyViewCriteria }>(),
deleteRequested: type<string>(),
},
});
export const myViewsApiEvents = eventGroup({
source: 'My Views API',
events: {
listed: type<MyView[]>(),
listFailed: type<string>(),
resultsLoaded: type<IssuePage>(),
resultsFailed: type<string>(),
saved: type<string>(),
saveFailed: type<string>(),
deleted: type<string>(),
deleteFailed: type<string>(),
},
});

View File

@@ -0,0 +1,41 @@
import { inject, Injectable } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import { MyViewCriteria } from '@task-stream/web/tracker/domain';
import { myViewsPageEvents } from '../events/my-views.events';
import { MyViewsStore, VIEW_LIMIT } from '../stores/my-views.store';
/** My saved views of «my work», across my projects. */
@Injectable({ providedIn: 'root' })
export class MyViewsFacade {
private readonly store = inject(MyViewsStore);
private readonly dispatch = injectDispatch(myViewsPageEvents);
/** By name. */
readonly views = this.store.views;
/** The one picked (null: «Mis tickets»). */
readonly selected = this.store.selected;
/** Its tickets, most recently updated first (up to `limit`). */
readonly results = this.store.results;
readonly total = this.store.total;
readonly limit = VIEW_LIMIT;
readonly loading = this.store.loading;
readonly saving = this.store.saving;
readonly error = this.store.error;
/** Loads my views (and refreshes the one picked). */
open(): void {
this.dispatch.opened();
}
select(viewId: string | null): void {
this.dispatch.selected(viewId);
}
save(name: string, criteria: MyViewCriteria): void {
this.dispatch.saveRequested({ name, criteria });
}
remove(viewId: string): void {
this.dispatch.deleteRequested(viewId);
}
}

View File

@@ -0,0 +1,180 @@
import { computed, inject } from '@angular/core';
import { mapResponse } from '@ngrx/operators';
import { signalStore, withComputed, withState } from '@ngrx/signals';
import {
Events,
on,
withEventHandlers,
withReducer,
} from '@ngrx/signals/events';
import {
IssuesGateway,
IssueSummary,
MyIssuesFilter,
MyView,
MyViewCriteria,
OPEN_CATEGORIES,
SavedFiltersGateway,
} from '@task-stream/web/tracker/domain';
import { filter, map, merge, mergeMap, switchMap, take, tap } from 'rxjs';
import { myViewsApiEvents, myViewsPageEvents } from '../events/my-views.events';
import { describeError, Notifications } from '../notifications';
/** Tickets a view lists at most. */
export const VIEW_LIMIT = 100;
/** A view's criteria as the «my work» search; no statuses: the open ones. */
export function viewFilter(criteria: MyViewCriteria): MyIssuesFilter {
return {
assignedToMe: !!criteria.assignedToMe,
watching: !!criteria.watching,
categories: criteria.categories?.length
? criteria.categories
: [...OPEN_CATEGORIES],
priorities: criteria.priorities ?? undefined,
projectIds: criteria.projectIds ?? undefined,
bugsOnly: !!criteria.bugsOnly,
overdue: !!criteria.overdue,
search: criteria.search ?? undefined,
limit: VIEW_LIMIT,
};
}
interface MyViewsState {
views: MyView[];
selectedId: string | null;
results: IssueSummary[];
total: number;
loading: boolean;
saving: boolean;
error: string | null;
}
/** My saved views of «my work» and the tickets of the one picked. Root-scoped. */
export const MyViewsStore = signalStore(
{ providedIn: 'root' },
withState<MyViewsState>({
views: [],
selectedId: null,
results: [],
total: 0,
loading: false,
saving: false,
error: null,
}),
withComputed((store) => ({
selected: computed(
() => store.views().find((v) => v.id === store.selectedId()) ?? null,
),
})),
withReducer(
on(myViewsApiEvents.listed, ({ payload }, state) => ({
views: payload,
// A view deleted elsewhere is no longer picked.
selectedId: payload.some((v) => v.id === state.selectedId)
? state.selectedId
: null,
})),
on(myViewsPageEvents.selected, ({ payload }) => ({
selectedId: payload,
results: [],
total: 0,
loading: payload !== null,
error: null,
})),
on(myViewsApiEvents.resultsLoaded, ({ payload }) => ({
results: payload.items,
total: payload.total,
loading: false,
})),
on(myViewsApiEvents.resultsFailed, ({ payload }) => ({
loading: false,
error: payload,
})),
on(myViewsPageEvents.saveRequested, () => ({ saving: true })),
on(myViewsApiEvents.saved, () => ({ saving: false })),
on(myViewsApiEvents.saveFailed, () => ({ saving: false })),
),
withEventHandlers(
(
store,
events = inject(Events),
filters = inject(SavedFiltersGateway),
issues = inject(IssuesGateway),
notifications = inject(Notifications),
) => ({
list$: merge(
events.on(myViewsPageEvents.opened),
events.on(myViewsApiEvents.saved, myViewsApiEvents.deleted),
).pipe(
switchMap(() =>
filters.myViews().pipe(
mapResponse({
next: (views) => myViewsApiEvents.listed(views),
error: (error) =>
myViewsApiEvents.listFailed(describeError(error)),
}),
),
),
),
// The picked view's tickets: on picking it, and again on opening.
results$: merge(
events
.on(myViewsPageEvents.selected)
.pipe(map(({ payload }) => payload)),
events.on(myViewsPageEvents.opened).pipe(map(() => store.selectedId())),
).pipe(
filter((id): id is string => !!id),
switchMap((id) => {
const view = store.views().find((v) => v.id === id);
if (!view) return [];
return issues.mine(viewFilter(view.criteria)).pipe(
mapResponse({
next: (page) => myViewsApiEvents.resultsLoaded(page),
error: (error) =>
myViewsApiEvents.resultsFailed(describeError(error)),
}),
);
}),
),
save$: events.on(myViewsPageEvents.saveRequested).pipe(
mergeMap(({ payload }) =>
filters.saveMyView(payload).pipe(
mapResponse({
next: (id) => myViewsApiEvents.saved(id),
error: (error) =>
myViewsApiEvents.saveFailed(describeError(error)),
}),
),
),
),
// Once listed again, the new view is the one shown.
selectSaved$: events.on(myViewsApiEvents.saved).pipe(
switchMap(({ payload }) =>
events.on(myViewsApiEvents.listed).pipe(
take(1),
map(() => myViewsPageEvents.selected(payload)),
),
),
),
delete$: events.on(myViewsPageEvents.deleteRequested).pipe(
mergeMap(({ payload }) =>
filters.removeMyView(payload).pipe(
mapResponse({
next: () => myViewsApiEvents.deleted(payload),
error: (error) =>
myViewsApiEvents.deleteFailed(describeError(error)),
}),
),
),
),
feedback$: events
.on(
myViewsApiEvents.saveFailed,
myViewsApiEvents.deleteFailed,
myViewsApiEvents.listFailed,
)
.pipe(tap(({ payload }) => notifications.error(payload))),
}),
),
);

View File

@@ -122,6 +122,12 @@ export interface MyIssuesFilter {
categories?: StatusCategory[]; categories?: StatusCategory[];
/** Matches key, title or description. */ /** Matches key, title or description. */
search?: string; search?: string;
projectIds?: string[];
priorities?: IssuePriority[];
/** Only types drawn with the bug icon. */
bugsOnly?: boolean;
/** Only open tickets past their due date. */
overdue?: boolean;
limit: number; limit: number;
} }

View File

@@ -87,6 +87,29 @@ export interface SavedFilterCriteria {
search?: string | null; search?: string | null;
} }
/** What a view of «my work» narrows to, across my projects. */
export interface MyViewCriteria {
/** Only these projects (empty: all of mine). */
projectIds?: string[] | null;
assignedToMe?: boolean | null;
watching?: boolean | null;
categories?: StatusCategory[] | null;
priorities?: IssuePriority[] | null;
/** Only types drawn with the bug icon. */
bugsOnly?: boolean | null;
/** Only open tickets past their due date. */
overdue?: boolean | null;
search?: string | null;
}
/** A named view of «my work», mine only. */
export interface MyView {
id: string;
name: string;
criteria: MyViewCriteria;
createdAt: string;
}
/** A named view of the board: private, or shared with the project. */ /** A named view of the board: private, or shared with the project. */
export interface SavedFilter { export interface SavedFilter {
id: string; id: string;

View File

@@ -25,6 +25,8 @@ import {
Release, Release,
ReleaseBurnup, ReleaseBurnup,
SavedFilter, SavedFilter,
MyView,
MyViewCriteria,
SavedFilterCriteria, SavedFilterCriteria,
Sprint, Sprint,
SprintBurndown, SprintBurndown,
@@ -243,6 +245,13 @@ export abstract class SavedFiltersGateway {
criteria: SavedFilterCriteria; criteria: SavedFilterCriteria;
}): Observable<string>; }): Observable<string>;
abstract remove(id: string): Observable<void>; abstract remove(id: string): Observable<void>;
/** My views of «my work», by name. */
abstract myViews(): Observable<MyView[]>;
abstract saveMyView(input: {
name: string;
criteria: MyViewCriteria;
}): Observable<string>;
abstract removeMyView(id: string): Observable<void>;
} }
export abstract class CommentsGateway { export abstract class CommentsGateway {

View File

@@ -102,6 +102,12 @@ export class GraphqlIssuesGateway extends IssuesGateway {
categories: filter.categories as never, categories: filter.categories as never,
limit: filter.limit, limit: filter.limit,
search: filter.search?.trim() || null, search: filter.search?.trim() || null,
projectIds: filter.projectIds?.length ? filter.projectIds : null,
priorities: filter.priorities?.length
? (filter.priorities as never)
: null,
bugsOnly: filter.bugsOnly ?? null,
overdue: filter.overdue ?? null,
}) })
.pipe( .pipe(
map((d) => ({ map((d) => ({

View File

@@ -1,11 +1,16 @@
import { inject, Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { import {
MyView,
MyViewCriteria,
SavedFilter, SavedFilter,
SavedFilterCriteria, SavedFilterCriteria,
SavedFiltersGateway, SavedFiltersGateway,
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs'; import { map, Observable } from 'rxjs';
import { import {
DeleteMyViewDocument,
MyViewsDocument,
SaveMyViewDocument,
DeleteSavedFilterDocument, DeleteSavedFilterDocument,
SavedFiltersDocument, SavedFiltersDocument,
SaveFilterDocument, SaveFilterDocument,
@@ -38,4 +43,25 @@ export class GraphqlSavedFiltersGateway extends SavedFiltersGateway {
.mutate(DeleteSavedFilterDocument, { id }) .mutate(DeleteSavedFilterDocument, { id })
.pipe(map(() => undefined)); .pipe(map(() => undefined));
} }
myViews(): Observable<MyView[]> {
return this.gql
.query(MyViewsDocument, {})
.pipe(map((d) => d.myViews as MyView[]));
}
saveMyView(input: {
name: string;
criteria: MyViewCriteria;
}): Observable<string> {
return this.gql
.mutate(SaveMyViewDocument, { input: input as never })
.pipe(map((d) => d.saveMyView));
}
removeMyView(id: string): Observable<void> {
return this.gql
.mutate(DeleteMyViewDocument, { id })
.pipe(map(() => undefined));
}
} }

View File

@@ -236,6 +236,18 @@ export type LinkIssuesInput = {
type: IssueLinkType; type: IssueLinkType;
}; };
export type MyViewCriteriaInput = {
assignedToMe?: boolean | null | undefined;
bugsOnly?: boolean | null | undefined;
categories?: Array<StatusCategory> | null | undefined;
overdue?: boolean | null | undefined;
priorities?: Array<IssuePriority> | null | undefined;
/** Empty: all my projects */
projectIds?: Array<string> | null | undefined;
search?: string | null | undefined;
watching?: boolean | null | undefined;
};
/** Why someone is told */ /** Why someone is told */
export type NotificationKind = export type NotificationKind =
| 'ADDED_TO_PROJECT' | 'ADDED_TO_PROJECT'
@@ -293,6 +305,11 @@ export type SaveFilterInput = {
shared?: boolean; shared?: boolean;
}; };
export type SaveMyViewInput = {
criteria: MyViewCriteriaInput;
name: string;
};
export type SavedFilterCriteriaInput = { export type SavedFilterCriteriaInput = {
areas?: Array<string> | null | undefined; areas?: Array<string> | null | undefined;
assigneeId?: string | null | undefined; assigneeId?: string | null | undefined;
@@ -1023,6 +1040,10 @@ export type MyIssuesQueryVariables = Exact<{
categories?: Array<StatusCategory> | StatusCategory | null | undefined; categories?: Array<StatusCategory> | StatusCategory | null | undefined;
limit: number; limit: number;
search?: string | null | undefined; search?: string | null | undefined;
projectIds?: Array<string> | string | null | undefined;
priorities?: Array<IssuePriority> | IssuePriority | null | undefined;
bugsOnly?: boolean | null | undefined;
overdue?: boolean | null | undefined;
}>; }>;
export type MyIssuesQuery = { export type MyIssuesQuery = {
@@ -2117,6 +2138,38 @@ export type DeleteSavedFilterMutationVariables = Exact<{
export type DeleteSavedFilterMutation = { deleteSavedFilter: boolean }; export type DeleteSavedFilterMutation = { deleteSavedFilter: boolean };
export type MyViewsQueryVariables = Exact<{ [key: string]: never }>;
export type MyViewsQuery = {
myViews: Array<{
id: string;
name: string;
createdAt: string;
criteria: {
projectIds: Array<string> | null;
assignedToMe: boolean | null;
watching: boolean | null;
categories: Array<StatusCategory> | null;
priorities: Array<IssuePriority> | null;
bugsOnly: boolean | null;
overdue: boolean | null;
search: string | null;
};
}>;
};
export type SaveMyViewMutationVariables = Exact<{
input: SaveMyViewInput;
}>;
export type SaveMyViewMutation = { saveMyView: string };
export type DeleteMyViewMutationVariables = Exact<{
id: string;
}>;
export type DeleteMyViewMutation = { deleteMyView: boolean };
export type SprintFieldsFragment = { export type SprintFieldsFragment = {
id: string; id: string;
projectId: string; projectId: string;
@@ -6604,6 +6657,53 @@ export const MyIssuesDocument = {
}, },
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
}, },
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'projectIds' },
},
type: {
kind: 'ListType',
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'priorities' },
},
type: {
kind: 'ListType',
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'IssuePriority' },
},
},
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'bugsOnly' },
},
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } },
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'overdue' },
},
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } },
},
], ],
selectionSet: { selectionSet: {
kind: 'SelectionSet', kind: 'SelectionSet',
@@ -6652,6 +6752,38 @@ export const MyIssuesDocument = {
name: { kind: 'Name', value: 'search' }, name: { kind: 'Name', value: 'search' },
}, },
}, },
{
kind: 'Argument',
name: { kind: 'Name', value: 'projectIds' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'projectIds' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'priorities' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'priorities' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'bugsOnly' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'bugsOnly' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'overdue' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'overdue' },
},
},
], ],
selectionSet: { selectionSet: {
kind: 'SelectionSet', kind: 'SelectionSet',
@@ -12637,6 +12769,161 @@ export const DeleteSavedFilterDocument = {
DeleteSavedFilterMutation, DeleteSavedFilterMutation,
DeleteSavedFilterMutationVariables DeleteSavedFilterMutationVariables
>; >;
export const MyViewsDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'MyViews' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'myViews' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'criteria' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'projectIds' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'assignedToMe' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'watching' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'categories' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'priorities' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'bugsOnly' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'overdue' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'search' },
},
],
},
},
],
},
},
],
},
},
],
} as unknown as DocumentNode<MyViewsQuery, MyViewsQueryVariables>;
export const SaveMyViewDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'SaveMyView' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'input' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'SaveMyViewInput' },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'saveMyView' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'input' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'input' },
},
},
],
},
],
},
},
],
} as unknown as DocumentNode<SaveMyViewMutation, SaveMyViewMutationVariables>;
export const DeleteMyViewDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'DeleteMyView' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } },
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'deleteMyView' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'id' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'id' },
},
},
],
},
],
},
},
],
} as unknown as DocumentNode<
DeleteMyViewMutation,
DeleteMyViewMutationVariables
>;
export const SprintsDocument = { export const SprintsDocument = {
kind: 'Document', kind: 'Document',
definitions: [ definitions: [

View File

@@ -136,6 +136,10 @@ query MyIssues(
$categories: [StatusCategory!] $categories: [StatusCategory!]
$limit: Int! $limit: Int!
$search: String $search: String
$projectIds: [ID!]
$priorities: [IssuePriority!]
$bugsOnly: Boolean
$overdue: Boolean
) { ) {
myIssues( myIssues(
assignedToMe: $assignedToMe assignedToMe: $assignedToMe
@@ -143,6 +147,10 @@ query MyIssues(
categories: $categories categories: $categories
limit: $limit limit: $limit
search: $search search: $search
projectIds: $projectIds
priorities: $priorities
bugsOnly: $bugsOnly
overdue: $overdue
) { ) {
total total
items { items {

View File

@@ -32,3 +32,29 @@ mutation SaveFilter($input: SaveFilterInput!) {
mutation DeleteSavedFilter($id: ID!) { mutation DeleteSavedFilter($id: ID!) {
deleteSavedFilter(id: $id) deleteSavedFilter(id: $id)
} }
query MyViews {
myViews {
id
name
createdAt
criteria {
projectIds
assignedToMe
watching
categories
priorities
bugsOnly
overdue
search
}
}
}
mutation SaveMyView($input: SaveMyViewInput!) {
saveMyView(input: $input)
}
mutation DeleteMyView($id: ID!) {
deleteMyView(id: $id)
}

View File

@@ -32,6 +32,7 @@ import {
typeLook, typeLook,
} from '../ui/copy'; } from '../ui/copy';
import { CalendarFeedComponent } from '../ui/calendar-feed'; import { CalendarFeedComponent } from '../ui/calendar-feed';
import { MyViewsComponent } from '../ui/my-views';
const VISIBLE = 12; const VISIBLE = 12;
@@ -49,6 +50,7 @@ const VISIBLE = 12;
PiIconComponent, PiIconComponent,
PiSkeletonComponent, PiSkeletonComponent,
CalendarFeedComponent, CalendarFeedComponent,
MyViewsComponent,
], ],
template: ` template: `
@if (!home.loaded()) { @if (!home.loaded()) {
@@ -60,6 +62,7 @@ const VISIBLE = 12;
</div> </div>
</div> </div>
} @else { } @else {
<ts-my-views />
<div class="ts-work"> <div class="ts-work">
<section class="ts-panel ts-work__panel" aria-labelledby="mine-title"> <section class="ts-panel ts-work__panel" aria-labelledby="mine-title">
<header class="ts-work__head"> <header class="ts-work__head">

View File

@@ -0,0 +1,481 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
OnInit,
signal,
TemplateRef,
viewChild,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import {
PiActionButtonComponent,
PiCheckboxComponent,
PiLabelDirective,
PiTextFieldComponent,
} from '@pi/ui';
import { DrawerService } from '@task-stream/web/shared/ui';
import {
MyViewsFacade,
ProjectsFacade,
} from '@task-stream/web/tracker/application';
import {
IssuePriority,
IssueSummary,
MyViewCriteria,
PRIORITY_ORDER,
StatusCategory,
Workflow,
} from '@task-stream/web/tracker/domain';
import {
CATEGORY,
PRIORITY,
projectKeyOf,
projectTone,
statusLook,
typeLook,
} from './copy';
const CATEGORIES = Object.values(StatusCategory);
/** Toggles a value in a list kept as a signal. */
function toggle<T>(
list: ReturnType<typeof signal<T[]>>,
value: T,
on: boolean,
) {
list.update((values) =>
on ? [...new Set([...values, value])] : values.filter((v) => v !== value),
);
}
/**
* My saved views of «my work», across my projects: a bar to pick one, its
* tickets, and the drawer to save a new one.
*/
@Component({
selector: 'ts-my-views',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
RouterLink,
PiActionButtonComponent,
PiCheckboxComponent,
PiLabelDirective,
PiTextFieldComponent,
],
template: `
<nav class="ts-views" aria-label="Mis vistas">
<span class="ts-views__label">Vistas</span>
@for (view of views.views(); track view.id) {
<button
type="button"
class="ts-views__chip"
[class.is-on]="views.selected()?.id === view.id"
[attr.aria-pressed]="views.selected()?.id === view.id"
(click)="
views.select(views.selected()?.id === view.id ? null : view.id)
"
>
{{ view.name }}
</button>
}
<button type="button" class="ts-views__new" (click)="openForm()">
+ Nueva vista
</button>
</nav>
@if (views.selected(); as view) {
<section class="ts-panel ts-views__panel" aria-labelledby="view-title">
<header class="ts-views__head">
<h2 id="view-title">{{ view.name }}</h2>
<span class="ts-muted">{{ summary() }}</span>
<span class="ts-muted ts-mono">{{ views.total() }}</span>
<span class="ts-spacer"></span>
<button
type="button"
class="ts-plain-link"
(click)="views.remove(view.id)"
>
Borrar vista
</button>
<button
type="button"
class="ts-plain-link"
(click)="views.select(null)"
>
Cerrar
</button>
</header>
@if (views.loading()) {
<p class="ts-muted">Cargando…</p>
} @else if (views.error(); as error) {
<p class="ts-muted">{{ error }}</p>
} @else {
<ul class="ts-views__list">
@for (issue of views.results(); track issue.id) {
<li>
<a
class="ts-views__row"
[routerLink]="['/p', keyOf(issue.key), 'issues', issue.key]"
[style.--type]="typeTone(issue)"
>
<span class="ts-key">{{ issue.key }}</span>
<span class="ts-views__title"
><span
class="ts-views__status"
[style.--tone]="statusTone(issue)"
>{{ statusLabel(issue) }}</span
>
{{ issue.title }}</span
>
<span class="ts-views__who">{{
issue.assignee?.name ?? 'Sin asignar'
}}</span>
<span
class="ts-views__project"
[style.--tone]="projectToneOf(issue.key)"
>{{ keyOf(issue.key) }}</span
>
</a>
</li>
} @empty {
<li class="ts-muted">Ningún ticket encaja ahora mismo.</li>
}
</ul>
@if (views.total() > views.results().length) {
<p class="ts-muted ts-views__more">
Se muestran {{ views.results().length }} de {{ views.total() }}.
</p>
}
}
</section>
}
<ng-template #form>
<form
class="ts-form ts-views__form"
novalidate
(submit)="$event.preventDefault(); save()"
>
<pi-text-field [(value)]="name" placeholder="Bugs críticos">
<pi-label [isRequired]="true">Nombre</pi-label>
</pi-text-field>
<fieldset>
<legend>Proyectos <span>(ninguno: todos los míos)</span></legend>
@for (project of projects.sorted(); track project.id) {
<pi-checkbox
[label]="project.key + ' · ' + project.name"
[checked]="projectIds().includes(project.id)"
(checkedChange)="pick(projectIds, project.id, $event)"
/>
}
</fieldset>
<fieldset>
<legend>Quién</legend>
<pi-checkbox
label="Asignados a mí"
[checked]="assignedToMe()"
(checkedChange)="assignedToMe.set($event)"
/>
<pi-checkbox
label="Que sigo"
[checked]="watching()"
(checkedChange)="watching.set($event)"
/>
</fieldset>
<fieldset>
<legend>Estado <span>(ninguno: los abiertos)</span></legend>
@for (category of categories; track category) {
<pi-checkbox
[label]="categoryLabel(category)"
[checked]="categoriesPicked().includes(category)"
(checkedChange)="pick(categoriesPicked, category, $event)"
/>
}
</fieldset>
<fieldset>
<legend>Prioridad <span>(ninguna: todas)</span></legend>
@for (priority of priorities; track priority) {
<pi-checkbox
[label]="priorityLabel(priority)"
[checked]="prioritiesPicked().includes(priority)"
(checkedChange)="pick(prioritiesPicked, priority, $event)"
/>
}
</fieldset>
<fieldset>
<legend>Además</legend>
<pi-checkbox
label="Solo bugs"
[checked]="bugsOnly()"
(checkedChange)="bugsOnly.set($event)"
/>
<pi-checkbox
label="Solo vencidos"
[checked]="overdue()"
(checkedChange)="overdue.set($event)"
/>
</fieldset>
<pi-text-field [(value)]="search" placeholder="pago, Safari…">
<pi-label>Texto (clave, título o descripción)</pi-label>
</pi-text-field>
<pi-action-button
size="w100"
color="primary"
[text]="views.saving() ? 'Guardando…' : 'Guardar vista'"
[disabled]="!name().trim() || views.saving()"
(execute)="save()"
/>
</form>
</ng-template>
`,
styles: `
:host {
display: grid;
gap: 1rem;
margin-bottom: 1.25rem;
}
.ts-views {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.ts-views__label {
color: var(--ts-muted);
font-size: 0.8125rem;
}
.ts-views__chip,
.ts-views__new {
padding: 0.3125rem 0.75rem;
border: 1px solid var(--ts-line);
border-radius: 999px;
background: var(--ts-surface);
color: var(--ts-ink);
font: inherit;
font-size: 0.8125rem;
cursor: pointer;
&:hover {
border-color: var(--ts-brand);
}
}
.ts-views__chip.is-on {
border-color: var(--ts-brand);
background: color-mix(in srgb, var(--ts-brand) 14%, var(--ts-surface));
font-weight: 600;
}
.ts-views__new {
border-style: dashed;
color: var(--ts-brand);
}
.ts-views__panel {
padding: 1.25rem 1.5rem;
}
.ts-views__head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.5rem 0.75rem;
margin-bottom: 0.75rem;
h2 {
margin: 0;
font-size: 1.125rem;
}
.ts-muted {
font-size: 0.8125rem;
}
}
.ts-spacer {
flex: 1;
}
.ts-views__list {
display: grid;
gap: 0.125rem;
margin: 0;
padding: 0;
list-style: none;
}
.ts-views__row {
display: grid;
grid-template-columns: 5.5rem minmax(0, 1fr) minmax(0, 9rem) 3.5rem;
align-items: center;
gap: 0.75rem;
padding: 0.375rem 0.5rem;
border-left: 3px solid var(--type, var(--ts-line));
border-radius: 0.375rem;
color: inherit;
font-size: 0.875rem;
text-decoration: none;
&:hover {
background: var(--pi-color-primary-25);
}
}
.ts-views__title,
.ts-views__who {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ts-views__who {
color: var(--ts-muted);
font-size: 0.8125rem;
}
.ts-views__status {
margin-right: 0.375rem;
color: var(--tone);
font-size: 0.75rem;
font-weight: 600;
}
.ts-views__project {
justify-self: end;
color: var(--tone);
font-family: var(--ts-font-mono);
font-size: 0.75rem;
}
.ts-views__more {
margin: 0.75rem 0 0;
font-size: 0.75rem;
}
.ts-views__form fieldset {
display: grid;
gap: 0.25rem;
margin: 0;
padding: 0;
border: 0;
}
.ts-views__form fieldset + pi-text-field {
margin-top: 0.75rem;
}
.ts-views__form legend {
margin-bottom: 0.25rem;
font-size: 0.875rem;
font-weight: 600;
span {
color: var(--ts-muted);
font-weight: 400;
}
}
@media (max-width: 640px) {
.ts-views__row {
grid-template-columns: 5rem minmax(0, 1fr) 3rem;
}
.ts-views__who {
display: none;
}
}
`,
})
export class MyViewsComponent implements OnInit {
protected readonly views = inject(MyViewsFacade);
protected readonly projects = inject(ProjectsFacade);
private readonly drawer = inject(DrawerService);
private readonly form = viewChild.required<TemplateRef<unknown>>('form');
protected readonly categories = CATEGORIES;
protected readonly priorities = PRIORITY_ORDER;
/* The form */
protected readonly name = signal('');
protected readonly projectIds = signal<string[]>([]);
protected readonly assignedToMe = signal(false);
protected readonly watching = signal(false);
protected readonly categoriesPicked = signal<StatusCategory[]>([]);
protected readonly prioritiesPicked = signal<IssuePriority[]>([]);
protected readonly bugsOnly = signal(false);
protected readonly overdue = signal(false);
protected readonly search = signal('');
ngOnInit(): void {
this.views.open();
}
protected readonly pick = toggle;
protected openForm(): void {
this.name.set('');
this.projectIds.set([]);
this.assignedToMe.set(false);
this.watching.set(false);
this.categoriesPicked.set([]);
this.prioritiesPicked.set([]);
this.bugsOnly.set(false);
this.overdue.set(false);
this.search.set('');
this.drawer.open('Nueva vista de mi trabajo', this.form());
}
protected save(): void {
const name = this.name().trim();
if (!name) return;
const criteria: MyViewCriteria = {
projectIds: this.projectIds(),
assignedToMe: this.assignedToMe(),
watching: this.watching(),
categories: this.categoriesPicked(),
priorities: this.prioritiesPicked(),
bugsOnly: this.bugsOnly(),
overdue: this.overdue(),
search: this.search().trim() || null,
};
this.views.save(name, criteria);
this.drawer.closeAll();
}
/** What the picked view narrows to, in words. */
protected readonly summary = computed(() => {
const c = this.views.selected()?.criteria;
if (!c) return '';
const keys = (c.projectIds ?? [])
.map((id) => this.projects.projects().find((p) => p.id === id)?.key)
.filter(Boolean);
return [
keys.length ? keys.join(', ') : 'todos mis proyectos',
c.assignedToMe ? 'asignados a mí' : null,
c.watching ? 'que sigo' : null,
c.categories?.length
? c.categories.map((x) => CATEGORY[x].label.toLowerCase()).join('/')
: 'abiertos',
c.priorities?.length
? c.priorities.map((p) => PRIORITY[p].label.toLowerCase()).join('/')
: null,
c.bugsOnly ? 'solo bugs' : null,
c.overdue ? 'vencidos' : null,
c.search ? `«${c.search}»` : null,
]
.filter(Boolean)
.join(' · ');
});
protected readonly categoryLabel = (c: StatusCategory) => CATEGORY[c].label;
protected readonly priorityLabel = (p: IssuePriority) => PRIORITY[p].label;
protected readonly keyOf = projectKeyOf;
protected readonly projectToneOf = (issueKey: string) =>
projectTone(projectKeyOf(issueKey));
/** Each ticket with its own project's names and colours. */
private readonly workflows = computed(
() =>
new Map(
this.projects
.projects()
.map((p) => [p.id, new Workflow(p.settings)] as const),
),
);
private workflowOf(issue: IssueSummary): Workflow {
return this.workflows().get(issue.projectId) ?? new Workflow();
}
protected readonly statusLabel = (issue: IssueSummary) =>
statusLook(this.workflowOf(issue), issue.status).label;
protected readonly statusTone = (issue: IssueSummary) =>
statusLook(this.workflowOf(issue), issue.status).tone;
protected readonly typeTone = (issue: IssueSummary) =>
typeLook(this.workflowOf(issue), issue.type).tone;
}