diff --git a/apps/api/e2e/tracker.e2e-spec.ts b/apps/api/e2e/tracker.e2e-spec.ts index ed4b9ce..43b7cc1 100644 --- a/apps/api/e2e/tracker.e2e-spec.ts +++ b/apps/api/e2e/tracker.e2e-spec.ts @@ -1441,4 +1441,74 @@ describe('task-stream (e2e)', () => { expect(later.body['quality'].bugWeeks).toHaveLength(8); expect(later.body['fields']).toEqual([]); }); + + it('keeps my own views of my work, across my projects', async () => { + const key = 'V' + Date.now().toString(36).slice(-5).toUpperCase(); + await rest('token-ana', 'POST', '/projects', { key, name: key }); + const project = await eventually( + () => rest('token-ana', 'GET', `/projects/${key}`), + (r) => r.status === 200, + ); + const bug = ( + await rest('token-ana', 'POST', `/projects/${key}/issues`, { + type: 'BUG', + title: 'Late bug', + priority: 'CRITICAL', + dueDate: '2020-01-01', + }) + ).body['key']; + await rest('token-ana', 'POST', `/projects/${key}/issues`, { + type: 'TASK', + title: 'Late task', + priority: 'CRITICAL', + dueDate: '2020-01-01', + }); + await eventually( + () => rest('token-ana', 'GET', `/issues/${key}-2`), + (r) => r.status === 200, + ); + + const criteria = { + projectIds: [project.body['id']], + priorities: ['CRITICAL'], + bugsOnly: true, + overdue: true, + }; + const saved = await rest('token-ana', 'POST', '/me/views', { + name: ' Bugs críticos vencidos ', + criteria, + }); + expect(saved.status).toBe(201); + const views = await rest('token-ana', 'GET', '/me/views'); + expect(views.body).toEqual([ + expect.objectContaining({ name: 'Bugs críticos vencidos', criteria }), + ]); + expect((await rest('token-bob', 'GET', '/me/views')).body).toEqual([]); + + // Running it: the same criteria on my work. + const page = await rest( + 'token-ana', + 'GET', + `/me/issues?assignedToMe=false&project=${project.body['id']}&priority=CRITICAL&bugsOnly=true&overdue=true`, + ); + expect(page.body['items'].map((i: { key: string }) => i.key)).toEqual([ + bug, + ]); + const { myIssues } = await gql( + 'token-ana', + `query($p: [ID!]) { myIssues(assignedToMe: false, projectIds: $p, overdue: true) { total } }`, + { p: [project.body['id']] }, + ); + expect(myIssues.total).toBe(2); + + // Only its owner deletes it. + const id = saved.body['id']; + expect((await rest('token-bob', 'DELETE', `/me/views/${id}`)).status).toBe( + 404, + ); + expect((await rest('token-ana', 'DELETE', `/me/views/${id}`)).status).toBe( + 204, + ); + expect((await rest('token-ana', 'GET', '/me/views')).body).toEqual([]); + }); }); diff --git a/apps/api/openapi.json b/apps/api/openapi.json index e7cb3b0..f79e9b8 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -2260,6 +2260,47 @@ "schema": { "type": "string" } + }, + { + "name": "project", + "required": false, + "in": "query", + "description": "Only these of my projects (ids). Repeat or separate with commas", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "priority", + "required": false, + "in": "query", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IssuePriority" + } + } + }, + { + "name": "bugsOnly", + "required": false, + "in": "query", + "description": "Only types with the bug icon", + "schema": { + "type": "boolean" + } + }, + { + "name": "overdue", + "required": false, + "in": "query", + "description": "Only open tickets past their due date", + "schema": { + "type": "boolean" + } } ], "responses": { @@ -7806,6 +7847,271 @@ ] } }, + "/api/me/views": { + "get": { + "operationId": "SavedFiltersController_myViews", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MyViewDto" + } + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "My saved views of my work, across my projects (by name)", + "tags": [ + "Saved filters" + ] + }, + "post": { + "description": "Run it with GET /api/me/issues and the same criteria (project, priority, bugsOnly, overdue…).", + "operationId": "SavedFiltersController_saveMyView", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveMyViewDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SavedFilterIdDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Save a view of my work under a name", + "tags": [ + "Saved filters" + ] + } + }, + "/api/me/views/{id}": { + "delete": { + "operationId": "SavedFiltersController_deleteMyView", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Delete one of my views", + "tags": [ + "Saved filters" + ] + } + }, "/api/projects/{project}/sprints": { "get": { "description": "The one under way, the planned ones, then the closed ones.", @@ -14193,6 +14499,132 @@ "id" ] }, + "MyViewCriteriaResponseDto": { + "type": "object", + "properties": { + "projectIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "assignedToMe": { + "type": "boolean" + }, + "watching": { + "type": "boolean" + }, + "categories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StatusCategory" + } + }, + "priorities": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IssuePriority" + } + }, + "bugsOnly": { + "type": "boolean" + }, + "overdue": { + "type": "boolean" + }, + "search": { + "type": "string" + } + } + }, + "MyViewDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "example": "Bugs críticos de todos mis proyectos" + }, + "criteria": { + "$ref": "#/components/schemas/MyViewCriteriaResponseDto" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "name", + "criteria", + "createdAt" + ] + }, + "MyViewCriteriaDto": { + "type": "object", + "properties": { + "projectIds": { + "description": "Only these of my projects (empty: all)", + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "assignedToMe": { + "type": "boolean" + }, + "watching": { + "type": "boolean", + "description": "Only the tickets I follow" + }, + "categories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StatusCategory" + } + }, + "priorities": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IssuePriority" + } + }, + "bugsOnly": { + "type": "boolean", + "description": "Only types with the bug icon" + }, + "overdue": { + "type": "boolean", + "description": "Only open tickets past due" + }, + "search": { + "type": "string", + "maxLength": 200 + } + } + }, + "SaveMyViewDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Bugs críticos de todos mis proyectos", + "maxLength": 60 + }, + "criteria": { + "$ref": "#/components/schemas/MyViewCriteriaDto" + } + }, + "required": [ + "name", + "criteria" + ] + }, "SprintStatus": { "type": "string", "enum": [ diff --git a/apps/api/schema.gql b/apps/api/schema.gql index 31b6430..51316b5 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -836,6 +836,9 @@ type Mutation { """Author or project admin""" deleteComment(commentId: ID!): CommandResult! + """Delete one of my views""" + deleteMyView(id: ID!): Boolean! + """ Delete the project (owner only), with its tickets, comments and releases. `confirmKey` must be the project key. """ @@ -893,6 +896,9 @@ type Mutation { """Save the current board filters under a name (members of the project)""" saveFilter(input: SaveFilterInput!): ID! + """Save a view of my work under a name""" + saveMyView(input: SaveMyViewInput!): ID! + """ Place a ticket under an epic or another ticket of the same project (null: back to the top). Three levels at most: epic → ticket → subtask. """ @@ -965,6 +971,45 @@ type MyNotifications { unread: Int! } +"""A named view of my work (private)""" +type MyView { + createdAt: String! + criteria: MyViewCriteria! + id: ID! + name: String! +} + +"""What a view of my work narrows to; empty: every ticket of my projects""" +type MyViewCriteria { + assignedToMe: Boolean + + """Only types with the bug icon""" + bugsOnly: Boolean + categories: [StatusCategory!] + + """Only open tickets past due""" + overdue: Boolean + priorities: [IssuePriority!] + + """Empty: all my projects""" + projectIds: [ID!] + search: String + watching: Boolean +} + +input MyViewCriteriaInput { + assignedToMe: Boolean + bugsOnly: Boolean + categories: [StatusCategory!] + overdue: Boolean + priorities: [IssuePriority!] + + """Empty: all my projects""" + projectIds: [ID!] + search: String + watching: Boolean +} + type Notification { actor: UserRef! createdAt: String! @@ -1234,10 +1279,20 @@ type Query { """ myIssues( assignedToMe: Boolean! = true + + """Only types drawn with the bug icon""" + bugsOnly: Boolean categories: [StatusCategory!] limit: Int! = 50 offset: Int! = 0 + """Only open tickets past their due date""" + overdue: Boolean + priorities: [IssuePriority!] + + """Only these of my projects""" + projectIds: [ID!] + """Matches key, title or description (global search)""" search: String statuses: [String!] @@ -1251,6 +1306,9 @@ type Query { """Projects where I am a member""" myProjects: [Project!]! + + """My saved views of my work, across my projects""" + myViews: [MyView!]! notificationSettings: NotificationSettings! """A project of mine, or any public one (no sign-in needed)""" @@ -1399,6 +1457,11 @@ input SaveFilterInput { shared: Boolean! = false } +input SaveMyViewInput { + criteria: MyViewCriteriaInput! + name: String! +} + """ A named view of the board: private to whoever saved it, or shared with the project """ diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 453fa6c..2a9638f 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -1188,6 +1188,44 @@ export interface paths { patch?: never; trace?: never; }; + "/api/me/views": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** My saved views of my work, across my projects (by name) */ + get: operations["SavedFiltersController_myViews"]; + put?: never; + /** + * Save a view of my work under a name + * @description Run it with GET /api/me/issues and the same criteria (project, priority, bugsOnly, overdue…). + */ + post: operations["SavedFiltersController_saveMyView"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/me/views/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete one of my views */ + delete: operations["SavedFiltersController_deleteMyView"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/projects/{project}/sprints": { parameters: { query?: never; @@ -2879,6 +2917,44 @@ export interface components { /** Format: uuid */ id: string; }; + MyViewCriteriaResponseDto: { + projectIds?: string[]; + assignedToMe?: boolean; + watching?: boolean; + categories?: components["schemas"]["StatusCategory"][]; + priorities?: components["schemas"]["IssuePriority"][]; + bugsOnly?: boolean; + overdue?: boolean; + search?: string; + }; + MyViewDto: { + /** Format: uuid */ + id: string; + /** @example Bugs críticos de todos mis proyectos */ + name: string; + criteria: components["schemas"]["MyViewCriteriaResponseDto"]; + /** Format: date-time */ + createdAt: string; + }; + MyViewCriteriaDto: { + /** @description Only these of my projects (empty: all) */ + projectIds?: string[]; + assignedToMe?: boolean; + /** @description Only the tickets I follow */ + watching?: boolean; + categories?: components["schemas"]["StatusCategory"][]; + priorities?: components["schemas"]["IssuePriority"][]; + /** @description Only types with the bug icon */ + bugsOnly?: boolean; + /** @description Only open tickets past due */ + overdue?: boolean; + search?: string; + }; + SaveMyViewDto: { + /** @example Bugs críticos de todos mis proyectos */ + name: string; + criteria: components["schemas"]["MyViewCriteriaDto"]; + }; /** @enum {string} */ SprintStatus: "PLANNED" | "ACTIVE" | "CLOSED"; SprintDto: { @@ -4600,6 +4676,13 @@ export interface operations { category?: components["schemas"]["StatusCategory"][]; /** @description Matches key, title or description */ search?: string; + /** @description Only these of my projects (ids). Repeat or separate with commas */ + project?: string[]; + priority?: components["schemas"]["IssuePriority"][]; + /** @description Only types with the bug icon */ + bugsOnly?: boolean; + /** @description Only open tickets past their due date */ + overdue?: boolean; }; header?: never; path?: never; @@ -8458,6 +8541,211 @@ export interface operations { }; }; }; + SavedFiltersController_myViews: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MyViewDto"][]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + SavedFiltersController_saveMyView: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SaveMyViewDto"]; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SavedFilterIdDto"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; + SavedFiltersController_deleteMyView: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Someone changed it at the same time: retry */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Business rule (e.g. a transition the workflow forbids) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; SprintsController_list: { parameters: { query?: never; diff --git a/apps/mcp/src/app/tools/issues.resolver.ts b/apps/mcp/src/app/tools/issues.resolver.ts index 73f9c38..41f5f71 100644 --- a/apps/mcp/src/app/tools/issues.resolver.ts +++ b/apps/mcp/src/app/tools/issues.resolver.ts @@ -88,6 +88,15 @@ export class IssuesResolver { .array(CATEGORY) .optional() .describe('Default: open ones (TODO, IN_PROGRESS, QA)'), + priorities: z.array(PRIORITY).optional(), + bugsOnly: z + .boolean() + .optional() + .describe('Only types drawn with the bug icon, in each project'), + overdue: z + .boolean() + .optional() + .describe('Only open tickets past their due date'), limit: limit(200, 50), }), annotations: READ, @@ -97,6 +106,9 @@ export class IssuesResolver { assignedToMe: boolean; watching: boolean; categories?: z.infer[]; + priorities?: z.infer[]; + bugsOnly?: boolean; + overdue?: boolean; limit: number; }, ctx: McpContext, @@ -109,6 +121,9 @@ export class IssuesResolver { assignedToMe: params.assignedToMe, watching: params.watching, category: params.categories ?? ['TODO', 'IN_PROGRESS', 'QA'], + priority: params.priorities, + bugsOnly: params.bugsOnly, + overdue: params.overdue, limit: params.limit, }, }, diff --git a/docs/03-api.md b/docs/03-api.md index 7444ea9..490f68b 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -435,7 +435,7 @@ Todas devuelven `CommandResult { id, key, version }`. | Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` | | Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` | | 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)` | | Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` | @@ -634,23 +634,23 @@ 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`). -| Recurso | Rutas | -| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 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) | -| 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` | -| 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` | -| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` | -| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` | -| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` | -| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | -| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) | -| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) | -| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` | -| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) | -| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) | -| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` | +| Recurso | Rutas | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 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) | +| 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` | +| 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}` | +| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` | +| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` | +| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | +| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) | +| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) | +| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` | +| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) | +| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) | +| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` | Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto diff --git a/docs/04-cliente-web.md b/docs/04-cliente-web.md index cbd16c8..ac5e8a0 100644 --- a/docs/04-cliente-web.md +++ b/docs/04-cliente-web.md @@ -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 | | `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 | +| `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 | | `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 | @@ -284,7 +285,7 @@ visualización de la plataforma: | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `/login` | Inicio de sesión (solo sin sesión) | | `/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) | | `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) | | `/p/:key/issues/:issueKey` | Ticket | diff --git a/docs/09-mcp.md b/docs/09-mcp.md index 774e829..512fd9f 100644 --- a/docs/09-mcp.md +++ b/docs/09-mcp.md @@ -50,7 +50,7 @@ flowchart LR | | `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_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` | | | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios | | | `create_issue` | Crear un ticket (también colgado de una épica) | diff --git a/docs/10-futuro.md b/docs/10-futuro.md index 9bc784d..e63c9a2 100644 --- a/docs/10-futuro.md +++ b/docs/10-futuro.md @@ -35,8 +35,6 @@ Nada: la tanda está hecha (ver 10.5). ### 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. -- **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 vista. - **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. - 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. +- 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». diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index 6566ec6..1a6ed9f 100644 Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf index 6fca3fb..8cd4ae2 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ diff --git a/libs/api/tracker/application/src/index.ts b/libs/api/tracker/application/src/index.ts index 8c44dd9..50c04c4 100644 --- a/libs/api/tracker/application/src/index.ts +++ b/libs/api/tracker/application/src/index.ts @@ -29,4 +29,6 @@ export * from './lib/webhooks/webhook-events'; export * from './lib/git/git'; export * from './lib/git/git-payloads'; export * from './lib/saved-filters/saved-filters'; +export * from './lib/saved-filters/my-views'; +export * from './lib/ports/my-views.repository'; export * from './lib/tracker-application.module'; diff --git a/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts b/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts index 35682c5..dc95e01 100644 --- a/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts +++ b/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts @@ -60,6 +60,11 @@ export interface CrossProjectIssueFilter { watcherId?: string; statuses?: string[]; categories?: StatusCategory[]; + priorities?: IssuePriority[]; + /** Project id → the only type ids taken there. */ + typesByProject?: Record; + /** Due before this day (`YYYY-MM-DD`, excluded). */ + dueBefore?: string; search?: string; limit: number; offset: number; diff --git a/libs/api/tracker/application/src/lib/ports/my-views.repository.ts b/libs/api/tracker/application/src/lib/ports/my-views.repository.ts new file mode 100644 index 0000000..bc7dbb9 --- /dev/null +++ b/libs/api/tracker/application/src/lib/ports/my-views.repository.ts @@ -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; + abstract findById(id: string): Promise; + abstract countByOwner(ownerId: string): Promise; + abstract insert(view: MyViewReadModel): Promise; + abstract delete(id: string): Promise; +} diff --git a/libs/api/tracker/application/src/lib/queries/search-my-issues.query-handler.ts b/libs/api/tracker/application/src/lib/queries/search-my-issues.query-handler.ts index e4481c9..6aa8a4f 100644 --- a/libs/api/tracker/application/src/lib/queries/search-my-issues.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/search-my-issues.query-handler.ts @@ -1,5 +1,9 @@ import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; -import { IssueReadModel } from '@task-stream/api/tracker/domain'; +import { + DEFAULT_PROJECT_SETTINGS, + IssueReadModel, + StatusCategory, +} from '@task-stream/api/tracker/domain'; import { FindIssuesRepository, Page } from '../ports/find-issues.repository'; import { FindProjectsRepository } from '../ports/find-projects.repository'; import { SearchMyIssuesQuery } from './search-my-issues.query'; @@ -15,13 +19,43 @@ export class SearchMyIssuesQueryHandler /** Membership is the access rule: only projects the viewer belongs to. */ async execute(query: SearchMyIssuesQuery): Promise> { - const projects = await this.findProjects.findByMember(query.viewerId); + const { filter } = query; + const projects = ( + await this.findProjects.findByMember(query.viewerId) + ).filter( + (p) => !filter.projectIds?.length || filter.projectIds.includes(p.id), + ); + // Bugs are each project's types drawn with the bug icon. + const bugTypes = filter.bugsOnly + ? Object.fromEntries( + projects.map((p) => [ + p.id, + (p.settings ?? DEFAULT_PROJECT_SETTINGS).issueTypes + .filter((t) => t.icon === 'bug') + .map((t) => t.id), + ]), + ) + : undefined; + const open = [ + StatusCategory.TODO, + StatusCategory.IN_PROGRESS, + StatusCategory.QA, + ]; return this.findIssues.searchAcross({ projectIds: projects.map((project) => project.id), + priorities: filter.priorities?.length ? filter.priorities : undefined, + typesByProject: bugTypes, + dueBefore: filter.overdue + ? new Date().toISOString().slice(0, 10) + : undefined, assigneeId: query.filter.assignedToMe ? query.viewerId : undefined, watcherId: query.filter.watching ? query.viewerId : undefined, statuses: query.filter.statuses, - categories: query.filter.categories, + categories: filter.overdue + ? (filter.categories?.length ? filter.categories : open).filter((c) => + open.includes(c), + ) + : query.filter.categories, search: query.filter.search?.trim().slice(0, 200) || undefined, limit: Math.min(query.filter.limit, 200), offset: query.filter.offset, diff --git a/libs/api/tracker/application/src/lib/queries/search-my-issues.query.ts b/libs/api/tracker/application/src/lib/queries/search-my-issues.query.ts index a7a6c37..04238a7 100644 --- a/libs/api/tracker/application/src/lib/queries/search-my-issues.query.ts +++ b/libs/api/tracker/application/src/lib/queries/search-my-issues.query.ts @@ -1,5 +1,6 @@ import { Query } from '@nestjs/cqrs'; import { + IssuePriority, IssueReadModel, StatusCategory, } from '@task-stream/api/tracker/domain'; @@ -15,6 +16,13 @@ export interface MyIssuesFilter { categories?: StatusCategory[]; /** Matches key, title or description. */ search?: string; + /** Only these of my projects. */ + projectIds?: string[]; + priorities?: IssuePriority[]; + /** Only tickets of a type drawn with the bug icon (in each project). */ + bugsOnly?: boolean; + /** Only open tickets past their due date. */ + overdue?: boolean; limit: number; offset: number; } diff --git a/libs/api/tracker/application/src/lib/saved-filters.service.ts b/libs/api/tracker/application/src/lib/saved-filters.service.ts index 4817ddf..a3e1743 100644 --- a/libs/api/tracker/application/src/lib/saved-filters.service.ts +++ b/libs/api/tracker/application/src/lib/saved-filters.service.ts @@ -2,9 +2,16 @@ import { CommandRunner } from '@task-stream/api/shared/application'; import { Injectable } from '@nestjs/common'; import { QueryBus } from '@nestjs/cqrs'; import { + MyViewCriteriaReadModel, + MyViewReadModel, SavedFilterCriteriaReadModel, SavedFilterReadModel, } from '@task-stream/api/tracker/domain'; +import { + DeleteMyViewCommand, + GetMyViewsQuery, + SaveMyViewCommand, +} from './saved-filters/my-views'; import { DeleteSavedFilterCommand, GetSavedFiltersQuery, @@ -18,7 +25,7 @@ export interface SaveFilterInput { criteria: SavedFilterCriteriaReadModel; } -/** Saved board views: mine and the ones shared with the project. */ +/** Saved views: the board's (mine and shared) and my own of «my work». */ @Injectable() export class SavedFiltersService { constructor( @@ -47,4 +54,22 @@ export class SavedFiltersService { new DeleteSavedFilterCommand(actorId, filterId), ); } + + /** My views of «my work», by name. */ + myViews(ownerId: string): Promise { + 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 { + return this.commands.execute(new DeleteMyViewCommand(ownerId, viewId)); + } } diff --git a/libs/api/tracker/application/src/lib/saved-filters/my-views.ts b/libs/api/tracker/application/src/lib/saved-filters/my-views.ts new file mode 100644 index 0000000..62bea17 --- /dev/null +++ b/libs/api/tracker/application/src/lib/saved-filters/my-views.ts @@ -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 { + constructor( + public readonly ownerId: string, + public readonly viewId: string, + ) { + super(); + } +} + +export class GetMyViewsQuery extends Query { + constructor(public readonly ownerId: string) { + super(); + } +} + +/** Drops empty criteria, trims the text and deduplicates lists. */ +export function normalizeMyView( + criteria: MyViewCriteriaReadModel, +): MyViewCriteriaReadModel { + const list = (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 +{ + 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 +{ + constructor(private readonly views: MyViewsRepository) {} + + async execute(command: DeleteMyViewCommand): Promise { + 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 +{ + constructor(private readonly views: MyViewsRepository) {} + + execute(query: GetMyViewsQuery): Promise { + return this.views.findByOwner(query.ownerId); + } +} + +export const MY_VIEWS = [ + SaveMyViewCommandHandler, + DeleteMyViewCommandHandler, + GetMyViewsQueryHandler, +]; diff --git a/libs/api/tracker/application/src/lib/tracker-application.module.ts b/libs/api/tracker/application/src/lib/tracker-application.module.ts index d645a0b..53bdb94 100644 --- a/libs/api/tracker/application/src/lib/tracker-application.module.ts +++ b/libs/api/tracker/application/src/lib/tracker-application.module.ts @@ -34,6 +34,7 @@ import { } from './notifications/notifications.commands'; import { SAVED_FILTERS } from './saved-filters/saved-filters'; +import { MY_VIEWS } from './saved-filters/my-views'; import { SendDueRemindersCommandHandler } from './notifications/reminders'; import { SavedFiltersService } from './saved-filters.service'; import { SprintsService } from './sprints.service'; @@ -101,6 +102,7 @@ const SERVICES = [ ...SAGAS, ...NOTIFICATIONS, ...SAVED_FILTERS, + ...MY_VIEWS, ...GIT_HANDLERS, ...WEBHOOK_HANDLERS, ...TRANSFER_HANDLERS, diff --git a/libs/api/tracker/domain/src/lib/read-models/saved-filter.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/saved-filter.read-model.ts index 21ba0cb..8a9468a 100644 --- a/libs/api/tracker/domain/src/lib/read-models/saved-filter.read-model.ts +++ b/libs/api/tracker/domain/src/lib/read-models/saved-filter.read-model.ts @@ -1,4 +1,5 @@ import { IssuePriority } from '../issue/issue.enums'; +import { StatusCategory } from '../project/project-settings'; import { UserRefReadModel } from './user-ref.read-model'; /** What a saved view of the board narrows the tickets to. Empty: everything. */ @@ -27,3 +28,31 @@ export class SavedFilterReadModel { criteria!: SavedFilterCriteriaReadModel; 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; +} diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts index 246268a..eb2a32f 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts @@ -172,14 +172,27 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { if (filter.statuses?.length) query['status'] = { $in: filter.statuses }; if (filter.categories?.length) 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[] = []; + if (filter.typesByProject) + and.push({ + $or: Object.entries(filter.typesByProject).map( + ([projectId, types]) => ({ + projectId, + type: { $in: types }, + }), + ), + }); if (filter.search?.trim()) { const pattern = new RegExp(escapeRegex(filter.search.trim()), 'i'); - query['$or'] = [ - { key: pattern }, - { title: pattern }, - { description: pattern }, - ]; + and.push({ + $or: [{ key: pattern }, { title: pattern }, { description: pattern }], + }); } + if (and.length) query['$and'] = and; const [docs, total] = await Promise.all([ this.issues .find(query) diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/my-views.repository.ts b/libs/api/tracker/infrastructure/src/lib/mongo/my-views.repository.ts new file mode 100644 index 0000000..2dbbb98 --- /dev/null +++ b/libs/api/tracker/infrastructure/src/lib/mongo/my-views.repository.ts @@ -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, + ) { + super(); + } + + async findByOwner(ownerId: string): Promise { + const docs = await this.views + .find({ ownerId }) + .sort({ name: 1 }) + .lean(); + return docs.map(toView); + } + + async findById(id: string): Promise { + const doc = await this.views.findOne({ id }).lean(); + return doc ? toView(doc) : null; + } + + countByOwner(ownerId: string): Promise { + return this.views.countDocuments({ ownerId }); + } + + async insert(view: MyViewReadModel): Promise { + await this.views.create(view); + } + + async delete(id: string): Promise { + 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 ?? {} }; +} diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts index 5a084af..cd7aa3d 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts @@ -12,6 +12,7 @@ import { ProjectReadModel, ReleaseReadModel, SavedFilterReadModel, + MyViewReadModel, SprintReadModel, implicitRank, } from '@task-stream/api/tracker/domain'; @@ -40,6 +41,7 @@ export type ActivityDoc = Doc; export type TimelineDoc = Doc; export type NotificationDoc = Doc; export type SavedFilterDoc = Doc; +export type MyViewDoc = Doc; export type OutgoingWebhookDoc = Doc; export type AutomationDoc = Doc; export interface AutomationRunDoc { @@ -94,6 +96,7 @@ export const ReadModels = { Notification: 'NotificationReadModel', NotificationSettings: 'NotificationSettings', SavedFilter: 'SavedFilter', + MyView: 'MyView', Sprint: 'SprintReadModel', GitIntegration: 'GitIntegration', OutgoingWebhook: 'OutgoingWebhook', @@ -302,6 +305,15 @@ export const SavedFilterSchema = SavedFilterSchema.index({ projectId: 1, 'owner.id': 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' }) class GitIntegrationDocument { /** The project's id. */ @@ -390,6 +402,7 @@ export const READ_MODEL_SCHEMAS = [ { name: ReadModels.Notification, schema: NotificationSchema }, { name: ReadModels.NotificationSettings, schema: NotificationSettingsSchema }, { name: ReadModels.SavedFilter, schema: SavedFilterSchema }, + { name: ReadModels.MyView, schema: MyViewSchema }, { name: ReadModels.CalendarFeed, schema: CalendarFeedSchema }, { name: ReadModels.PushSubscription, schema: PushSubscriptionSchema }, ]; diff --git a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts index 400b7e2..54f6706 100644 --- a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts +++ b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts @@ -11,6 +11,7 @@ import { PushSubscriptionsRepository, PushSender, SavedFiltersRepository, + MyViewsRepository, GitIntegrationsRepository, OutgoingWebhooksRepository, AutomationsRepository, @@ -72,6 +73,7 @@ import { MongoCalendarFeedsRepository } from './mongo/calendar-feeds.repository' import { MongoPushSubscriptionsRepository } from './mongo/push-subscriptions.repository'; import { WebPushSender } from './push/web-push.sender'; import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository'; +import { MongoMyViewsRepository } from './mongo/my-views.repository'; import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository'; import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository'; import { HttpWebhookSender } from './webhooks/http-webhook.sender'; @@ -143,6 +145,7 @@ const PORTS = [ }, { provide: PushSender, useClass: WebPushSender }, { provide: SavedFiltersRepository, useClass: MongoSavedFiltersRepository }, + { provide: MyViewsRepository, useClass: MongoMyViewsRepository }, { provide: GitIntegrationsRepository, useClass: MongoGitIntegrationsRepository, diff --git a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts index d4b2c5e..a002c14 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts @@ -784,3 +784,39 @@ export class PushSubscriptionInput { @Field() @IsString() @MaxLength(200) p256dh!: string; @Field() @IsString() @MaxLength(100) auth!: string; } + +@InputType() +export class MyViewCriteriaInput { + @Field(() => [ID], { nullable: true, description: 'Empty: all my projects' }) + @IsOptional() + @IsArray() + @ArrayMaxSize(50) + @IsUUID('all', { each: true }) + projectIds?: string[]; + @Field({ nullable: true }) @IsOptional() @IsBoolean() assignedToMe?: boolean; + @Field({ nullable: true }) @IsOptional() @IsBoolean() watching?: boolean; + @Field(() => [StatusCategory], { nullable: true }) + @IsOptional() + @IsEnum(StatusCategory, { each: true }) + categories?: StatusCategory[]; + @Field(() => [IssuePriority], { nullable: true }) + @IsOptional() + @IsEnum(IssuePriority, { each: true }) + priorities?: IssuePriority[]; + @Field({ nullable: true }) @IsOptional() @IsBoolean() bugsOnly?: boolean; + @Field({ nullable: true }) @IsOptional() @IsBoolean() overdue?: boolean; + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + @MaxLength(200) + search?: string | null; +} + +@InputType() +export class SaveMyViewInput { + @Field() @IsString() @MinLength(1) @MaxLength(60) name!: string; + @Field(() => MyViewCriteriaInput) + @ValidateNested() + @Type(() => MyViewCriteriaInput) + criteria!: MyViewCriteriaInput; +} diff --git a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts index 18b9116..1795696 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts @@ -20,6 +20,7 @@ import { WorkflowsService, } from '@task-stream/api/tracker/application'; import { + IssuePriority, IssueReadModel, StatusCategory, } from '@task-stream/api/tracker/domain'; @@ -121,6 +122,26 @@ export class IssueResolver { description: 'Matches key, title or description (global search)', }) search?: string | null, + @Args('projectIds', { + type: () => [ID], + nullable: true, + description: 'Only these of my projects', + }) + projectIds?: string[] | null, + @Args('priorities', { type: () => [IssuePriority], nullable: true }) + priorities?: IssuePriority[] | null, + @Args('bugsOnly', { + type: () => Boolean, + nullable: true, + description: 'Only types drawn with the bug icon', + }) + bugsOnly?: boolean | null, + @Args('overdue', { + type: () => Boolean, + nullable: true, + description: 'Only open tickets past their due date', + }) + overdue?: boolean | null, ) { return this.issuesService.mine(me.id, { assignedToMe, @@ -128,6 +149,10 @@ export class IssueResolver { statuses: statuses ?? undefined, categories: categories ?? undefined, search: search ?? undefined, + projectIds: projectIds ?? undefined, + priorities: priorities ?? undefined, + bugsOnly: bugsOnly ?? undefined, + overdue: overdue ?? undefined, limit, offset, }); diff --git a/libs/api/tracker/presenters/src/lib/graphql/saved-filter.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/saved-filter.resolver.ts index ee729ff..aef6497 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/saved-filter.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/saved-filter.resolver.ts @@ -2,8 +2,8 @@ import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql'; import { User } from '@task-stream/api/identity/domain'; import { CurrentUser } from '@task-stream/api/identity/presenters'; import { SavedFiltersService } from '@task-stream/api/tracker/application'; -import { SaveFilterInput } from './inputs'; -import { SavedFilterGql } from './types'; +import { SaveFilterInput, SaveMyViewInput } from './inputs'; +import { MyViewGql, SavedFilterGql } from './types'; /** Saved views of a project's board. */ @Resolver(() => SavedFilterGql) @@ -41,4 +41,36 @@ export class SavedFilterResolver { await this.filters.delete(me.id, id); return true; } + + @Query(() => [MyViewGql], { + description: 'My saved views of my work, across my projects', + }) + myViews(@CurrentUser() me: User) { + return this.filters.myViews(me.id); + } + + @Mutation(() => ID, { description: 'Save a view of my work under a name' }) + async saveMyView( + @CurrentUser() me: User, + @Args('input') input: SaveMyViewInput, + ) { + return ( + await this.filters.saveMyView(me.id, { + name: input.name, + criteria: { + ...input.criteria, + search: input.criteria.search ?? undefined, + }, + }) + ).id; + } + + @Mutation(() => Boolean, { description: 'Delete one of my views' }) + async deleteMyView( + @CurrentUser() me: User, + @Args('id', { type: () => ID }) id: string, + ) { + await this.filters.deleteMyView(me.id, id); + return true; + } } diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index 8b95871..46b6513 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -456,6 +456,34 @@ export class SavedFilterGql { @Field() createdAt!: string; } +@ObjectType('MyViewCriteria', { + description: + 'What a view of my work narrows to; empty: every ticket of my projects', +}) +export class MyViewCriteriaGql { + @Field(() => [ID], { nullable: true, description: 'Empty: all my projects' }) + projectIds?: string[]; + @Field({ nullable: true }) assignedToMe?: boolean; + @Field({ nullable: true }) watching?: boolean; + @Field(() => [StatusCategory], { nullable: true }) + categories?: StatusCategory[]; + @Field(() => [IssuePriority], { nullable: true }) + priorities?: IssuePriority[]; + @Field({ nullable: true, description: 'Only types with the bug icon' }) + bugsOnly?: boolean; + @Field({ nullable: true, description: 'Only open tickets past due' }) + overdue?: boolean; + @Field(() => String, { nullable: true }) search?: string | null; +} + +@ObjectType('MyView', { description: 'A named view of my work (private)' }) +export class MyViewGql { + @Field(() => ID) id!: string; + @Field() name!: string; + @Field(() => MyViewCriteriaGql) criteria!: MyViewCriteriaGql; + @Field() createdAt!: string; +} + @ObjectType('IssuePage') export class IssuePageGql { @Field(() => [IssueGql]) items!: IssueGql[]; diff --git a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts index 8a00608..e425258 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts @@ -500,6 +500,38 @@ export class MyIssuesQueryDto extends PageQueryDto { @IsString() @MaxLength(200) 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 { @@ -898,6 +930,69 @@ export class SavedFilterCriteriaDto { 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 { @ApiProperty({ example: 'Bugs críticos de frontend', maxLength: 60 }) @IsString() diff --git a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts index f6a390b..889aced 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts @@ -526,6 +526,37 @@ export class SavedFilterDto { @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 { @ApiProperty({ format: 'uuid' }) id!: string; } diff --git a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts index b77402f..12a3209 100644 --- a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts @@ -132,6 +132,10 @@ export class IssuesController { statuses: query.status, categories: query.category, search: query.search, + projectIds: query.project, + priorities: query.priority, + bugsOnly: query.bugsOnly, + overdue: query.overdue, limit: query.limit, offset: query.offset, }); diff --git a/libs/api/tracker/presenters/src/lib/http/saved-filters.controller.ts b/libs/api/tracker/presenters/src/lib/http/saved-filters.controller.ts index f3586b5..b839707 100644 --- a/libs/api/tracker/presenters/src/lib/http/saved-filters.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/saved-filters.controller.ts @@ -26,8 +26,12 @@ import { ApiReadErrors, } from '@task-stream/api/shared/presenters'; import { SavedFiltersService } from '@task-stream/api/tracker/application'; -import { SaveFilterDto } from './dto/requests.dto'; -import { SavedFilterDto, SavedFilterIdDto } from './dto/responses.dto'; +import { SaveFilterDto, SaveMyViewDto } from './dto/requests.dto'; +import { + MyViewDto, + SavedFilterDto, + SavedFilterIdDto, +} from './dto/responses.dto'; import { Refs } from './refs'; /** 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); } + + @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); + } } diff --git a/libs/web/tracker/application/src/index.ts b/libs/web/tracker/application/src/index.ts index f747c91..ace6c64 100644 --- a/libs/web/tracker/application/src/index.ts +++ b/libs/web/tracker/application/src/index.ts @@ -10,6 +10,7 @@ export * from './lib/facades/explore.facade'; export * from './lib/facades/inbox.facade'; export * from './lib/facades/epics.facade'; export * from './lib/facades/insights.facade'; +export * from './lib/facades/my-views.facade'; export * from './lib/facades/backlog.facade'; export * from './lib/facades/list.facade'; export * from './lib/facades/propose.facade'; diff --git a/libs/web/tracker/application/src/lib/events/my-views.events.ts b/libs/web/tracker/application/src/lib/events/my-views.events.ts new file mode 100644 index 0000000..2fa6e0f --- /dev/null +++ b/libs/web/tracker/application/src/lib/events/my-views.events.ts @@ -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(), + /** null: back to «Mis tickets». */ + selected: type(), + saveRequested: type<{ name: string; criteria: MyViewCriteria }>(), + deleteRequested: type(), + }, +}); + +export const myViewsApiEvents = eventGroup({ + source: 'My Views API', + events: { + listed: type(), + listFailed: type(), + resultsLoaded: type(), + resultsFailed: type(), + saved: type(), + saveFailed: type(), + deleted: type(), + deleteFailed: type(), + }, +}); diff --git a/libs/web/tracker/application/src/lib/facades/my-views.facade.ts b/libs/web/tracker/application/src/lib/facades/my-views.facade.ts new file mode 100644 index 0000000..88d80ad --- /dev/null +++ b/libs/web/tracker/application/src/lib/facades/my-views.facade.ts @@ -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); + } +} diff --git a/libs/web/tracker/application/src/lib/stores/my-views.store.ts b/libs/web/tracker/application/src/lib/stores/my-views.store.ts new file mode 100644 index 0000000..e907bae --- /dev/null +++ b/libs/web/tracker/application/src/lib/stores/my-views.store.ts @@ -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({ + 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))), + }), + ), +); diff --git a/libs/web/tracker/domain/src/lib/models/inputs.ts b/libs/web/tracker/domain/src/lib/models/inputs.ts index 0dfb847..44ecadc 100644 --- a/libs/web/tracker/domain/src/lib/models/inputs.ts +++ b/libs/web/tracker/domain/src/lib/models/inputs.ts @@ -122,6 +122,12 @@ export interface MyIssuesFilter { categories?: StatusCategory[]; /** Matches key, title or description. */ 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; } diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index 20234f6..eb897ac 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -87,6 +87,29 @@ export interface SavedFilterCriteria { 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. */ export interface SavedFilter { id: string; diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index 9f46857..b6d9c66 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -25,6 +25,8 @@ import { Release, ReleaseBurnup, SavedFilter, + MyView, + MyViewCriteria, SavedFilterCriteria, Sprint, SprintBurndown, @@ -243,6 +245,13 @@ export abstract class SavedFiltersGateway { criteria: SavedFilterCriteria; }): Observable; abstract remove(id: string): Observable; + /** My views of «my work», by name. */ + abstract myViews(): Observable; + abstract saveMyView(input: { + name: string; + criteria: MyViewCriteria; + }): Observable; + abstract removeMyView(id: string): Observable; } export abstract class CommentsGateway { diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts index 15fae45..c537894 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts @@ -102,6 +102,12 @@ export class GraphqlIssuesGateway extends IssuesGateway { categories: filter.categories as never, limit: filter.limit, 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( map((d) => ({ diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-saved-filters.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-saved-filters.gateway.ts index 384bace..af17bd9 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-saved-filters.gateway.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-saved-filters.gateway.ts @@ -1,11 +1,16 @@ import { inject, Injectable } from '@angular/core'; import { + MyView, + MyViewCriteria, SavedFilter, SavedFilterCriteria, SavedFiltersGateway, } from '@task-stream/web/tracker/domain'; import { map, Observable } from 'rxjs'; import { + DeleteMyViewDocument, + MyViewsDocument, + SaveMyViewDocument, DeleteSavedFilterDocument, SavedFiltersDocument, SaveFilterDocument, @@ -38,4 +43,25 @@ export class GraphqlSavedFiltersGateway extends SavedFiltersGateway { .mutate(DeleteSavedFilterDocument, { id }) .pipe(map(() => undefined)); } + + myViews(): Observable { + return this.gql + .query(MyViewsDocument, {}) + .pipe(map((d) => d.myViews as MyView[])); + } + + saveMyView(input: { + name: string; + criteria: MyViewCriteria; + }): Observable { + return this.gql + .mutate(SaveMyViewDocument, { input: input as never }) + .pipe(map((d) => d.saveMyView)); + } + + removeMyView(id: string): Observable { + return this.gql + .mutate(DeleteMyViewDocument, { id }) + .pipe(map(() => undefined)); + } } diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index c227b4e..ea240c5 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -236,6 +236,18 @@ export type LinkIssuesInput = { type: IssueLinkType; }; +export type MyViewCriteriaInput = { + assignedToMe?: boolean | null | undefined; + bugsOnly?: boolean | null | undefined; + categories?: Array | null | undefined; + overdue?: boolean | null | undefined; + priorities?: Array | null | undefined; + /** Empty: all my projects */ + projectIds?: Array | null | undefined; + search?: string | null | undefined; + watching?: boolean | null | undefined; +}; + /** Why someone is told */ export type NotificationKind = | 'ADDED_TO_PROJECT' @@ -293,6 +305,11 @@ export type SaveFilterInput = { shared?: boolean; }; +export type SaveMyViewInput = { + criteria: MyViewCriteriaInput; + name: string; +}; + export type SavedFilterCriteriaInput = { areas?: Array | null | undefined; assigneeId?: string | null | undefined; @@ -1023,6 +1040,10 @@ export type MyIssuesQueryVariables = Exact<{ categories?: Array | StatusCategory | null | undefined; limit: number; search?: string | null | undefined; + projectIds?: Array | string | null | undefined; + priorities?: Array | IssuePriority | null | undefined; + bugsOnly?: boolean | null | undefined; + overdue?: boolean | null | undefined; }>; export type MyIssuesQuery = { @@ -2117,6 +2138,38 @@ export type DeleteSavedFilterMutationVariables = Exact<{ 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 | null; + assignedToMe: boolean | null; + watching: boolean | null; + categories: Array | null; + priorities: Array | 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 = { id: string; projectId: string; @@ -6604,6 +6657,53 @@ export const MyIssuesDocument = { }, 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: { kind: 'SelectionSet', @@ -6652,6 +6752,38 @@ export const MyIssuesDocument = { 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: { kind: 'SelectionSet', @@ -12637,6 +12769,161 @@ export const DeleteSavedFilterDocument = { DeleteSavedFilterMutation, 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; +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; +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 = { kind: 'Document', definitions: [ diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql index b685cf8..6943fc2 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql @@ -136,6 +136,10 @@ query MyIssues( $categories: [StatusCategory!] $limit: Int! $search: String + $projectIds: [ID!] + $priorities: [IssuePriority!] + $bugsOnly: Boolean + $overdue: Boolean ) { myIssues( assignedToMe: $assignedToMe @@ -143,6 +147,10 @@ query MyIssues( categories: $categories limit: $limit search: $search + projectIds: $projectIds + priorities: $priorities + bugsOnly: $bugsOnly + overdue: $overdue ) { total items { diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/saved-filters.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/saved-filters.graphql index 52fa614..02dde42 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/saved-filters.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/saved-filters.graphql @@ -32,3 +32,29 @@ mutation SaveFilter($input: SaveFilterInput!) { mutation 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) +} diff --git a/libs/web/tracker/presenters/src/lib/pages/my-work.ts b/libs/web/tracker/presenters/src/lib/pages/my-work.ts index 60da027..80e5fd0 100644 --- a/libs/web/tracker/presenters/src/lib/pages/my-work.ts +++ b/libs/web/tracker/presenters/src/lib/pages/my-work.ts @@ -32,6 +32,7 @@ import { typeLook, } from '../ui/copy'; import { CalendarFeedComponent } from '../ui/calendar-feed'; +import { MyViewsComponent } from '../ui/my-views'; const VISIBLE = 12; @@ -49,6 +50,7 @@ const VISIBLE = 12; PiIconComponent, PiSkeletonComponent, CalendarFeedComponent, + MyViewsComponent, ], template: ` @if (!home.loaded()) { @@ -60,6 +62,7 @@ const VISIBLE = 12; } @else { +
diff --git a/libs/web/tracker/presenters/src/lib/ui/my-views.ts b/libs/web/tracker/presenters/src/lib/ui/my-views.ts new file mode 100644 index 0000000..7383f39 --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/my-views.ts @@ -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( + list: ReturnType>, + 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: ` + + + @if (views.selected(); as view) { +
+
+

{{ view.name }}

+ {{ summary() }} + {{ views.total() }} + + + +
+ @if (views.loading()) { +

Cargando…

+ } @else if (views.error(); as error) { +

{{ error }}

+ } @else { + + @if (views.total() > views.results().length) { +

+ Se muestran {{ views.results().length }} de {{ views.total() }}. +

+ } + } +
+ } + + +
+ + Nombre + + +
+ Proyectos (ninguno: todos los míos) + @for (project of projects.sorted(); track project.id) { + + } +
+ +
+ Quién + + +
+ +
+ Estado (ninguno: los abiertos) + @for (category of categories; track category) { + + } +
+ +
+ Prioridad (ninguna: todas) + @for (priority of priorities; track priority) { + + } +
+ +
+ Además + + +
+ + + Texto (clave, título o descripción) + + + + +
+ `, + 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>('form'); + + protected readonly categories = CATEGORIES; + protected readonly priorities = PRIORITY_ORDER; + + /* The form */ + protected readonly name = signal(''); + protected readonly projectIds = signal([]); + protected readonly assignedToMe = signal(false); + protected readonly watching = signal(false); + protected readonly categoriesPicked = signal([]); + protected readonly prioritiesPicked = signal([]); + 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; +}