feat: pinned tickets, at hand on the home page and in search

Each person pins up to 20 tickets from the ticket's page («Fijar»). They
show as a strip under the projects on the home page and in the search
palette before typing. Private and without history, like the views of
«my work» (Mongo `pinned_issues`); tickets in the trash or out of reach
are left out, and when the list is full the unreadable ones are dropped
first.

API: GET /api/me/pins, PUT/DELETE /api/issues/{issue}/pin; GraphQL
myPins, pinIssue, unpinIssue and Issue.pinned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 10:15:28 +02:00
parent 423f82c1a3
commit dfa42a8a1a
32 changed files with 1612 additions and 46 deletions

View File

@@ -2515,6 +2515,267 @@
] ]
} }
}, },
"/api/me/pins": {
"get": {
"description": "Across my projects; tickets in the trash or out of my reach are left out.",
"operationId": "IssuesController_myPins",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/IssueDto"
}
}
}
}
},
"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 pinned tickets, most recently pinned first",
"tags": [
"Issues"
]
}
},
"/api/issues/{issue}/pin": {
"put": {
"description": "Private to me; at most 20. Pinning it again changes nothing.",
"operationId": "IssuesController_pin",
"parameters": [
{
"name": "issue",
"required": true,
"in": "path",
"description": "Ticket id or key",
"schema": {
"example": "WEB-12",
"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": "Pin a ticket: keep it at hand on my home page and in search",
"tags": [
"Issues"
]
},
"delete": {
"operationId": "IssuesController_unpin",
"parameters": [
{
"name": "issue",
"required": true,
"in": "path",
"description": "Ticket id or key",
"schema": {
"example": "WEB-12",
"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": "Unpin a ticket",
"tags": [
"Issues"
]
}
},
"/api/issues/{issue}/watch": { "/api/issues/{issue}/watch": {
"put": { "put": {
"description": "Followers are told when it moves, is reassigned or gets a comment. Commenting follows it too.", "description": "Followers are told when it moves, is reassigned or gets a comment. Commenting follows it too.",

View File

@@ -662,6 +662,9 @@ type Issue {
"""The epic or ticket this one breaks down""" """The epic or ticket this one breaks down"""
parent: IssueParent parent: IssueParent
"""Whether I pinned it (false without a token)"""
pinned: Boolean!
priority: IssuePriority! priority: IssuePriority!
projectId: ID! projectId: ID!
projectKey: String! projectKey: String!
@@ -973,6 +976,11 @@ type Mutation {
"""Mark these as read, or all of them when `ids` is omitted""" """Mark these as read, or all of them when `ids` is omitted"""
markNotificationsRead(ids: [ID!]): Boolean! markNotificationsRead(ids: [ID!]): Boolean!
"""
Pin a ticket: keep it at hand on my home page and in search (private, at most 20)
"""
pinIssue(issueId: ID!): Boolean!
""" """
Ship the release. Fails while it has unfinished tickets unless force = true. Ship the release. Fails while it has unfinished tickets unless force = true.
""" """
@@ -1046,6 +1054,9 @@ type Mutation {
unarchiveProject(projectId: ID!): CommandResult! unarchiveProject(projectId: ID!): CommandResult!
unlinkIssues(input: LinkIssuesInput!): CommandResult! unlinkIssues(input: LinkIssuesInput!): CommandResult!
"""Unpin a ticket"""
unpinIssue(issueId: ID!): Boolean!
"""The link stops working""" """The link stops working"""
unshareDashboard(projectId: ID!): Boolean! unshareDashboard(projectId: ID!): Boolean!
@@ -1532,6 +1543,11 @@ type Query {
"""My notifications, newest first, and how many are unread""" """My notifications, newest first, and how many are unread"""
myNotifications(limit: Int! = 30, unreadOnly: Boolean! = false): MyNotifications! myNotifications(limit: Int! = 30, unreadOnly: Boolean! = false): MyNotifications!
"""
My pinned tickets, most recently pinned first (trashed or out of reach left out)
"""
myPins: [Issue!]!
"""Projects where I am a member""" """Projects where I am a member"""
myProjects: [Project!]! myProjects: [Project!]!

View File

@@ -461,6 +461,47 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/me/pins": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* My pinned tickets, most recently pinned first
* @description Across my projects; tickets in the trash or out of my reach are left out.
*/
get: operations["IssuesController_myPins"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/issues/{issue}/pin": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Pin a ticket: keep it at hand on my home page and in search
* @description Private to me; at most 20. Pinning it again changes nothing.
*/
put: operations["IssuesController_pin"];
post?: never;
/** Unpin a ticket */
delete: operations["IssuesController_unpin"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/issues/{issue}/watch": { "/api/issues/{issue}/watch": {
parameters: { parameters: {
query?: never; query?: never;
@@ -5277,6 +5318,209 @@ export interface operations {
}; };
}; };
}; };
IssuesController_myPins: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["IssueDto"][];
};
};
/** @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"];
};
};
};
};
IssuesController_pin: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Ticket id or key */
issue: 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"];
};
};
};
};
IssuesController_unpin: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Ticket id or key */
issue: 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"];
};
};
};
};
IssuesController_watch: { IssuesController_watch: {
parameters: { parameters: {
query?: never; query?: never;

View File

@@ -24,7 +24,7 @@
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden | | 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
| 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo | | 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo |
| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta | | ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta |
| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets y proyectos de todos tus proyectos; `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista | | ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets y proyectos de todos tus proyectos (antes de escribir, tus tickets fijados); `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista |
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias y flechas entre las que dependen unas de otras | | 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias y flechas entre las que dependen unas de otras |
| 🗳️ Propuestas y votos | Un proyecto público puede abrir un formulario para que cualquiera, sin cuenta, reporte un error o proponga una idea; quien tenga sesión vota lo que quiere | | 🗳️ Propuestas y votos | Un proyecto público puede abrir un formulario para que cualquiera, sin cuenta, reporte un error o proponga una idea; quien tenga sesión vota lo que quiere |
| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) | | 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) |

View File

@@ -396,7 +396,11 @@ eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien lo abre, quien lo ti
sigue un ticket (`WATCHED`) sabe cuándo cambia de estado, se asigna, se comenta, cambia su prioridad o su fecha límite, sigue un ticket (`WATCHED`) sabe cuándo cambia de estado, se asigna, se comenta, cambia su prioridad o su fecha límite,
o entra o sale de un sprint; las ediciones menores (título, etiquetas…) no avisan. «Los que sigo»: `watching` en o entra o sale de un sprint; las ediciones menores (título, etiquetas…) no avisan. «Los que sigo»: `watching` en
`myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`, `myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`,
`?watching=true`; sin sesión, ninguno). El id es `?watching=true`; sin sesión, ninguno). **Fijar**: cada persona fija hasta 20 tickets para tenerlos a mano en el
inicio y en la búsqueda (`pinIssue`/`unpinIssue`, `myPins`, `Issue.pinned`; `GET /api/me/pins`,
`PUT/DELETE /api/issues/{issue}/pin`). Son privados y sin historial (colección `pinned_issues`, como las vistas de «Mi
trabajo»); los que están en la papelera o fuera de su alcance no se listan, y al llegar a 20 se olvidan primero los
que ya no puede leer. El id es
`<agregado>:<versión>:<persona>`, así que reentregar un evento no duplica avisos. Llegan en vivo por la subscription `<agregado>:<versión>:<persona>`, así que reentregar un evento no duplica avisos. Llegan en vivo por la subscription
`notificationAdded` y, si la instalación tiene `SMTP_URL`, también por correo a quien lo active (`email`, desactivado `notificationAdded` y, si la instalación tiene `SMTP_URL`, también por correo a quien lo active (`email`, desactivado
por defecto); un correo que falla se registra y no bloquea el stream. por defecto); un correo que falla se registra y no bloquea el stream.
@@ -508,9 +512,9 @@ que funcionan también sin token.
Todas devuelven `CommandResult { id, key, version }`. Todas devuelven `CommandResult { id, key, version }`.
| Ámbito | Mutations | | Ámbito | Mutations |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` | | Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` | | Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `pinIssue`, `unpinIssue` (devuelven `true`), `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` |
| Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment` | | Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment` |
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` | | Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` | | Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
@@ -715,10 +719,10 @@ Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`). Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
| Recurso | Rutas | | Recurso | Rutas |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` | | Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) | | Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
| Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `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/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` | | Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `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`, `GET /api/me/pins` y `PUT/DELETE …/pin` (fijados), `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions` | | Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions` |
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` | | Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` | | Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |

View File

@@ -301,11 +301,11 @@ visualización de la plataforma:
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `/login` | Inicio de sesión (solo sin sesión) | | `/login` | Inicio de sesión (solo sin sesión) |
| `/auth/callback` | Retorno de Pireon (PKCE) | | `/auth/callback` | Retorno de Pireon (PKCE) |
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» | | `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» |
| `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos | | `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos |
| `/explore` | Proyectos públicos (sin sesión) | | `/explore` | Proyectos públicos (sin sesión) |
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace) | | `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace) |
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno) | | `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda |
| `/p/:key/releases` | Releases | | `/p/:key/releases` | Releases |
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) | | `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea | | `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |

View File

@@ -33,6 +33,9 @@ export * from './lib/git/git-payloads';
export * from './lib/saved-filters/saved-filters'; export * from './lib/saved-filters/saved-filters';
export * from './lib/saved-filters/my-views'; export * from './lib/saved-filters/my-views';
export * from './lib/ports/my-views.repository'; export * from './lib/ports/my-views.repository';
export * from './lib/saved-filters/pins';
export * from './lib/ports/pins.repository';
export * from './lib/pins.service';
export * from './lib/tracker-application.module'; export * from './lib/tracker-application.module';
export * from './lib/ports/presence.store'; export * from './lib/ports/presence.store';
export * from './lib/presence.service'; export * from './lib/presence.service';

View File

@@ -0,0 +1,31 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
import {
GetMyPinsQuery,
IsIssuePinnedQuery,
PinIssueCommand,
} from './saved-filters/pins';
/** Pinned tickets: the few someone keeps at hand, across projects. */
@Injectable()
export class PinsService {
constructor(
private readonly commands: CommandRunner,
private readonly queryBus: QueryBus,
) {}
/** Mine, most recently pinned first. */
mine(ownerId: string): Promise<IssueReadModel[]> {
return this.queryBus.execute(new GetMyPinsQuery(ownerId));
}
isPinned(ownerId: string, issueId: string): Promise<boolean> {
return this.queryBus.execute(new IsIssuePinnedQuery(ownerId, issueId));
}
pin(ownerId: string, issueId: string, pinned: boolean): Promise<void> {
return this.commands.execute(new PinIssueCommand(ownerId, issueId, pinned));
}
}

View File

@@ -0,0 +1,12 @@
import { PinnedIssueReadModel } from '@task-stream/api/tracker/domain';
/** Pinned tickets: one person's shortcuts, no history. */
export abstract class PinsRepository {
/** Most recently pinned first. */
abstract findByOwner(ownerId: string): Promise<PinnedIssueReadModel[]>;
abstract isPinned(ownerId: string, issueId: string): Promise<boolean>;
abstract countByOwner(ownerId: string): Promise<number>;
/** Pinning again keeps the first date. */
abstract pin(pin: PinnedIssueReadModel): Promise<void>;
abstract unpin(ownerId: string, issueId: string): Promise<void>;
}

View File

@@ -0,0 +1,142 @@
import {
CommandBus,
CqrsModule,
IQueryHandler,
QueryBus,
QueryHandler,
} from '@nestjs/cqrs';
import { Test } from '@nestjs/testing';
import {
BusinessRuleError,
NotFoundError,
} from '@task-stream/api/shared/domain';
import {
IssueReadModel,
PinnedIssueReadModel,
} from '@task-stream/api/tracker/domain';
import { PinsRepository } from '../ports/pins.repository';
import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query';
import {
GetMyPinsQuery,
IsIssuePinnedQuery,
MAX_PINS,
PINS,
PinIssueCommand,
} from './pins';
class InMemoryPins extends PinsRepository {
rows: PinnedIssueReadModel[] = [];
async findByOwner(ownerId: string) {
return this.rows
.filter((p) => p.ownerId === ownerId)
.sort((a, b) => b.pinnedAt.localeCompare(a.pinnedAt));
}
async isPinned(ownerId: string, issueId: string) {
return this.rows.some(
(p) => p.ownerId === ownerId && p.issueId === issueId,
);
}
async countByOwner(ownerId: string) {
return this.rows.filter((p) => p.ownerId === ownerId).length;
}
async pin(pin: PinnedIssueReadModel) {
if (!(await this.isPinned(pin.ownerId, pin.issueId))) this.rows.push(pin);
}
async unpin(ownerId: string, issueId: string) {
this.rows = this.rows.filter(
(p) => !(p.ownerId === ownerId && p.issueId === issueId),
);
}
}
/** What each person can read: `ana` all but `hidden`, in the trash `old`. */
const issues = new Map<string, IssueReadModel>();
const readable = (viewerId: string | null, id: string) =>
viewerId === 'ana' && id !== 'hidden';
@QueryHandler(GetIssuesByIdsQuery)
class FakeIssuesByIds implements IQueryHandler<GetIssuesByIdsQuery> {
async execute(query: GetIssuesByIdsQuery) {
return query.ids
.filter((id) => readable(query.viewerId, id))
.map(
(id) =>
issues.get(id) ??
({
id,
trashedAt: id === 'old' ? '2026-01-01' : null,
} as IssueReadModel),
);
}
}
async function setup() {
const rows = new InMemoryPins();
const moduleRef = await Test.createTestingModule({
imports: [CqrsModule.forRoot()],
providers: [
...PINS,
FakeIssuesByIds,
{ provide: PinsRepository, useValue: rows },
],
}).compile();
await moduleRef.init();
return {
rows,
commands: moduleRef.get(CommandBus),
queries: moduleRef.get(QueryBus),
};
}
describe('pinned tickets', () => {
it('pins, lists most recent first and unpins', async () => {
const { commands, queries } = await setup();
await commands.execute(new PinIssueCommand('ana', 'a', true));
await new Promise((r) => setTimeout(r, 2));
await commands.execute(new PinIssueCommand('ana', 'b', true));
// Twice is still once.
await commands.execute(new PinIssueCommand('ana', 'b', true));
const mine = await queries.execute(new GetMyPinsQuery('ana'));
expect(mine.map((i: IssueReadModel) => i.id)).toEqual(['b', 'a']);
expect(await queries.execute(new IsIssuePinnedQuery('ana', 'a'))).toBe(
true,
);
await commands.execute(new PinIssueCommand('ana', 'a', false));
expect(await queries.execute(new IsIssuePinnedQuery('ana', 'a'))).toBe(
false,
);
});
it('does not pin what I cannot read, nor what is in the trash', async () => {
const { commands } = await setup();
await expect(
commands.execute(new PinIssueCommand('ana', 'hidden', true)),
).rejects.toBeInstanceOf(NotFoundError);
await expect(
commands.execute(new PinIssueCommand('ana', 'old', true)),
).rejects.toBeInstanceOf(NotFoundError);
});
it('keeps a pin to a trashed ticket but does not list it', async () => {
const { commands, queries, rows } = await setup();
await commands.execute(new PinIssueCommand('ana', 'a', true));
await rows.pin({ ownerId: 'ana', issueId: 'old', pinnedAt: '2026-01-02' });
const mine = await queries.execute(new GetMyPinsQuery('ana'));
expect(mine.map((i: IssueReadModel) => i.id)).toEqual(['a']);
expect(rows.rows).toHaveLength(2);
});
it(`stops at ${MAX_PINS}, after forgetting the ones out of reach`, async () => {
const { commands, rows } = await setup();
for (let i = 0; i < MAX_PINS - 1; i++)
await rows.pin({ ownerId: 'ana', issueId: `t${i}`, pinnedAt: `${i}` });
await rows.pin({ ownerId: 'ana', issueId: 'hidden', pinnedAt: 'x' });
// Full, but one is out of reach: it makes room.
await commands.execute(new PinIssueCommand('ana', 'new', true));
expect(await rows.isPinned('ana', 'hidden')).toBe(false);
expect(await rows.countByOwner('ana')).toBe(MAX_PINS);
await expect(
commands.execute(new PinIssueCommand('ana', 'one-more', true)),
).rejects.toBeInstanceOf(BusinessRuleError);
});
});

View File

@@ -0,0 +1,150 @@
import {
Command,
CommandHandler,
ICommandHandler,
IQueryHandler,
Query,
QueryBus,
QueryHandler,
} from '@nestjs/cqrs';
import {
BusinessRuleError,
NotFoundError,
} from '@task-stream/api/shared/domain';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
import { PinsRepository } from '../ports/pins.repository';
import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query';
/*
* Pinned tickets: the few someone keeps at hand, on their home page and in
* the search palette. Private, across projects, no history (like the views
* of «my work»).
*/
/** A person keeps at most this many. */
export const MAX_PINS = 20;
export class PinIssueCommand extends Command<void> {
constructor(
public readonly ownerId: string,
public readonly issueId: string,
/** False: unpin. */
public readonly pinned: boolean,
) {
super();
}
}
/** My pinned tickets that I can still read and are not in the trash. */
export class GetMyPinsQuery extends Query<IssueReadModel[]> {
constructor(public readonly ownerId: string) {
super();
}
}
export class IsIssuePinnedQuery extends Query<boolean> {
constructor(
public readonly ownerId: string,
public readonly issueId: string,
) {
super();
}
}
@CommandHandler(PinIssueCommand)
export class PinIssueCommandHandler
implements ICommandHandler<PinIssueCommand>
{
constructor(
private readonly pins: PinsRepository,
private readonly queryBus: QueryBus,
) {}
async execute(command: PinIssueCommand): Promise<void> {
const { ownerId, issueId } = command;
if (!command.pinned) return this.pins.unpin(ownerId, issueId);
const [issue] = await this.queryBus.execute(
new GetIssuesByIdsQuery(ownerId, [issueId]),
);
// A ticket I cannot read does not exist, as far as I know.
if (!issue || issue.trashedAt) throw new NotFoundError('Issue', issueId);
if (await this.pins.isPinned(ownerId, issueId)) return;
if (
(await this.pins.countByOwner(ownerId)) >= MAX_PINS &&
(await this.dropUnreadable(ownerId)) >= MAX_PINS
)
throw new BusinessRuleError(
`You already keep ${MAX_PINS} pinned tickets; unpin one first`,
);
await this.pins.pin({
ownerId,
issueId,
pinnedAt: new Date().toISOString(),
});
}
/**
* Forgets the pins of tickets I can no longer read (I left the project,
* it was deleted) so they do not hold a place; returns how many remain.
*/
private async dropUnreadable(ownerId: string): Promise<number> {
const pins = await this.pins.findByOwner(ownerId);
const readable = new Set(
(
await this.queryBus.execute(
new GetIssuesByIdsQuery(
ownerId,
pins.map((p) => p.issueId),
),
)
).map((issue) => issue.id),
);
for (const pin of pins)
if (!readable.has(pin.issueId))
await this.pins.unpin(ownerId, pin.issueId);
return readable.size;
}
}
@QueryHandler(GetMyPinsQuery)
export class GetMyPinsQueryHandler
implements IQueryHandler<GetMyPinsQuery, IssueReadModel[]>
{
constructor(
private readonly pins: PinsRepository,
private readonly queryBus: QueryBus,
) {}
async execute(query: GetMyPinsQuery): Promise<IssueReadModel[]> {
const pins = await this.pins.findByOwner(query.ownerId);
if (pins.length === 0) return [];
const issues: IssueReadModel[] = await this.queryBus.execute(
new GetIssuesByIdsQuery(
query.ownerId,
pins.map((p) => p.issueId),
),
);
// In the trash or out of reach: kept, in case it comes back.
const byId = new Map(
issues.filter((i) => !i.trashedAt).map((i) => [i.id, i]),
);
return pins.flatMap((p) => byId.get(p.issueId) ?? []);
}
}
@QueryHandler(IsIssuePinnedQuery)
export class IsIssuePinnedQueryHandler
implements IQueryHandler<IsIssuePinnedQuery, boolean>
{
constructor(private readonly pins: PinsRepository) {}
execute(query: IsIssuePinnedQuery): Promise<boolean> {
return this.pins.isPinned(query.ownerId, query.issueId);
}
}
export const PINS = [
PinIssueCommandHandler,
GetMyPinsQueryHandler,
IsIssuePinnedQueryHandler,
];

View File

@@ -35,6 +35,8 @@ import {
import { SAVED_FILTERS } from './saved-filters/saved-filters'; import { SAVED_FILTERS } from './saved-filters/saved-filters';
import { MY_VIEWS } from './saved-filters/my-views'; import { MY_VIEWS } from './saved-filters/my-views';
import { PINS } from './saved-filters/pins';
import { PinsService } from './pins.service';
import { SendDueRemindersCommandHandler } from './notifications/reminders'; import { SendDueRemindersCommandHandler } from './notifications/reminders';
import { SavedFiltersService } from './saved-filters.service'; import { SavedFiltersService } from './saved-filters.service';
import { SprintsService } from './sprints.service'; import { SprintsService } from './sprints.service';
@@ -78,6 +80,7 @@ const SERVICES = [
BlockersService, BlockersService,
NotificationsService, NotificationsService,
SavedFiltersService, SavedFiltersService,
PinsService,
SprintsService, SprintsService,
GitService, GitService,
WebhooksService, WebhooksService,
@@ -108,6 +111,7 @@ const SERVICES = [
...NOTIFICATIONS, ...NOTIFICATIONS,
...SAVED_FILTERS, ...SAVED_FILTERS,
...MY_VIEWS, ...MY_VIEWS,
...PINS,
...GIT_HANDLERS, ...GIT_HANDLERS,
...WEBHOOK_HANDLERS, ...WEBHOOK_HANDLERS,
...TRANSFER_HANDLERS, ...TRANSFER_HANDLERS,

View File

@@ -56,3 +56,10 @@ export class MyViewReadModel {
criteria!: MyViewCriteriaReadModel; criteria!: MyViewCriteriaReadModel;
createdAt!: string; createdAt!: string;
} }
/** A ticket someone keeps at hand: private to them, no history. */
export class PinnedIssueReadModel {
ownerId!: string;
issueId!: string;
pinnedAt!: string;
}

View File

@@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { PinsRepository } from '@task-stream/api/tracker/application';
import { PinnedIssueReadModel } from '@task-stream/api/tracker/domain';
import { Model } from 'mongoose';
import { PinnedIssueDoc, ReadModels } from './read-model.schemas';
@Injectable()
export class MongoPinsRepository extends PinsRepository {
constructor(
@InjectModel(ReadModels.PinnedIssue)
private readonly pins: Model<PinnedIssueDoc>,
) {
super();
}
async findByOwner(ownerId: string): Promise<PinnedIssueReadModel[]> {
const docs = await this.pins
.find({ ownerId })
.sort({ pinnedAt: -1 })
.lean<PinnedIssueDoc[]>();
return docs.map(({ ownerId, issueId, pinnedAt }) => ({
ownerId,
issueId,
pinnedAt,
}));
}
async isPinned(ownerId: string, issueId: string): Promise<boolean> {
return !!(await this.pins.exists({ ownerId, issueId }));
}
countByOwner(ownerId: string): Promise<number> {
return this.pins.countDocuments({ ownerId });
}
async pin(pin: PinnedIssueReadModel): Promise<void> {
await this.pins.updateOne(
{ ownerId: pin.ownerId, issueId: pin.issueId },
{ $setOnInsert: pin },
{ upsert: true },
);
}
async unpin(ownerId: string, issueId: string): Promise<void> {
await this.pins.deleteOne({ ownerId, issueId });
}
}

View File

@@ -13,6 +13,7 @@ import {
ReleaseReadModel, ReleaseReadModel,
SavedFilterReadModel, SavedFilterReadModel,
MyViewReadModel, MyViewReadModel,
PinnedIssueReadModel,
SprintReadModel, SprintReadModel,
implicitRank, implicitRank,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
@@ -42,6 +43,7 @@ export type TimelineDoc = Doc<IssueTimelineEntry>;
export type NotificationDoc = Doc<NotificationReadModel>; export type NotificationDoc = Doc<NotificationReadModel>;
export type SavedFilterDoc = Doc<SavedFilterReadModel>; export type SavedFilterDoc = Doc<SavedFilterReadModel>;
export type MyViewDoc = Doc<MyViewReadModel>; export type MyViewDoc = Doc<MyViewReadModel>;
export type PinnedIssueDoc = PinnedIssueReadModel & { _id?: Types.ObjectId };
export type OutgoingWebhookDoc = Doc<OutgoingWebhook>; export type OutgoingWebhookDoc = Doc<OutgoingWebhook>;
export type AutomationDoc = Doc<Automation>; export type AutomationDoc = Doc<Automation>;
export interface AutomationRunDoc { export interface AutomationRunDoc {
@@ -104,6 +106,7 @@ export const ReadModels = {
NotificationSettings: 'NotificationSettings', NotificationSettings: 'NotificationSettings',
SavedFilter: 'SavedFilter', SavedFilter: 'SavedFilter',
MyView: 'MyView', MyView: 'MyView',
PinnedIssue: 'PinnedIssue',
Sprint: 'SprintReadModel', Sprint: 'SprintReadModel',
GitIntegration: 'GitIntegration', GitIntegration: 'GitIntegration',
OutgoingWebhook: 'OutgoingWebhook', OutgoingWebhook: 'OutgoingWebhook',
@@ -322,6 +325,16 @@ class MyViewDocument {
} }
export const MyViewSchema = SchemaFactory.createForClass(MyViewDocument); export const MyViewSchema = SchemaFactory.createForClass(MyViewDocument);
@Schema({ ...LOOSE, collection: 'pinned_issues' })
class PinnedIssueDocument {
@Prop({ type: String }) ownerId!: string;
@Prop({ type: String }) issueId!: string;
@Prop({ type: String }) pinnedAt!: string;
}
export const PinnedIssueSchema =
SchemaFactory.createForClass(PinnedIssueDocument);
PinnedIssueSchema.index({ ownerId: 1, issueId: 1 }, { unique: true });
@Schema({ ...LOOSE, collection: 'git_integrations' }) @Schema({ ...LOOSE, collection: 'git_integrations' })
class GitIntegrationDocument { class GitIntegrationDocument {
/** The project's id. */ /** The project's id. */
@@ -424,6 +437,7 @@ export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.NotificationSettings, schema: NotificationSettingsSchema }, { name: ReadModels.NotificationSettings, schema: NotificationSettingsSchema },
{ name: ReadModels.SavedFilter, schema: SavedFilterSchema }, { name: ReadModels.SavedFilter, schema: SavedFilterSchema },
{ name: ReadModels.MyView, schema: MyViewSchema }, { name: ReadModels.MyView, schema: MyViewSchema },
{ name: ReadModels.PinnedIssue, schema: PinnedIssueSchema },
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema }, { name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
{ name: ReadModels.DashboardShare, schema: DashboardShareSchema }, { name: ReadModels.DashboardShare, schema: DashboardShareSchema },
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema }, { name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },

View File

@@ -13,6 +13,7 @@ import {
PushSender, PushSender,
SavedFiltersRepository, SavedFiltersRepository,
MyViewsRepository, MyViewsRepository,
PinsRepository,
GitIntegrationsRepository, GitIntegrationsRepository,
OutgoingWebhooksRepository, OutgoingWebhooksRepository,
AutomationsRepository, AutomationsRepository,
@@ -78,6 +79,7 @@ import { MongoPushSubscriptionsRepository } from './mongo/push-subscriptions.rep
import { WebPushSender } from './push/web-push.sender'; import { WebPushSender } from './push/web-push.sender';
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository'; import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
import { MongoMyViewsRepository } from './mongo/my-views.repository'; import { MongoMyViewsRepository } from './mongo/my-views.repository';
import { MongoPinsRepository } from './mongo/pins.repository';
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository'; import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository'; import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
import { HttpWebhookSender } from './webhooks/http-webhook.sender'; import { HttpWebhookSender } from './webhooks/http-webhook.sender';
@@ -154,6 +156,7 @@ const PORTS = [
{ provide: PushSender, useClass: WebPushSender }, { provide: PushSender, useClass: WebPushSender },
{ provide: SavedFiltersRepository, useClass: MongoSavedFiltersRepository }, { provide: SavedFiltersRepository, useClass: MongoSavedFiltersRepository },
{ provide: MyViewsRepository, useClass: MongoMyViewsRepository }, { provide: MyViewsRepository, useClass: MongoMyViewsRepository },
{ provide: PinsRepository, useClass: MongoPinsRepository },
{ {
provide: GitIntegrationsRepository, provide: GitIntegrationsRepository,
useClass: MongoGitIntegrationsRepository, useClass: MongoGitIntegrationsRepository,

View File

@@ -17,6 +17,7 @@ import {
BlockersService, BlockersService,
CommentsService, CommentsService,
IssuesService, IssuesService,
PinsService,
PresenceService, PresenceService,
WorkflowsService, WorkflowsService,
} from '@task-stream/api/tracker/application'; } from '@task-stream/api/tracker/application';
@@ -60,6 +61,7 @@ export class IssueResolver {
private readonly workflows: WorkflowsService, private readonly workflows: WorkflowsService,
private readonly blockers: BlockersService, private readonly blockers: BlockersService,
private readonly presence: PresenceService, private readonly presence: PresenceService,
private readonly pins: PinsService,
) {} ) {}
@Mutation(() => [PresenceGql], { @Mutation(() => [PresenceGql], {
@@ -245,6 +247,16 @@ export class IssueResolver {
return !!me && issue.voterIds.includes(me.id); return !!me && issue.voterIds.includes(me.id);
} }
@ResolveField(() => Boolean, {
description: 'Whether I pinned it (false without a token)',
})
pinned(
@CurrentUser() me: User | undefined,
@Parent() issue: IssueReadModel,
): Promise<boolean> | boolean {
return !!me && this.pins.isPinned(me.id, issue.id);
}
@ResolveField(() => [IssueGql], { description: 'Linked tickets I can see' }) @ResolveField(() => [IssueGql], { description: 'Linked tickets I can see' })
linkedIssues( linkedIssues(
@CurrentUser() me: User | undefined, @CurrentUser() me: User | undefined,
@@ -317,6 +329,35 @@ export class IssueResolver {
return this.issuesService.watch(me.id, issueId, true); return this.issuesService.watch(me.id, issueId, true);
} }
@Query(() => [IssueGql], {
description:
'My pinned tickets, most recently pinned first (trashed or out of reach left out)',
})
myPins(@CurrentUser() me: User) {
return this.pins.mine(me.id);
}
@Mutation(() => Boolean, {
description:
'Pin a ticket: keep it at hand on my home page and in search (private, at most 20)',
})
async pinIssue(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
): Promise<boolean> {
await this.pins.pin(me.id, issueId, true);
return true;
}
@Mutation(() => Boolean, { description: 'Unpin a ticket' })
async unpinIssue(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
): Promise<boolean> {
await this.pins.pin(me.id, issueId, false);
return true;
}
@Mutation(() => CommandResultGql, { description: 'Stop following a ticket' }) @Mutation(() => CommandResultGql, { description: 'Stop following a ticket' })
unwatchIssue( unwatchIssue(
@CurrentUser() me: User, @CurrentUser() me: User,

View File

@@ -32,6 +32,7 @@ import {
import { import {
CommentsService, CommentsService,
IssuesService, IssuesService,
PinsService,
PresenceService, PresenceService,
ProjectsService, ProjectsService,
} from '@task-stream/api/tracker/application'; } from '@task-stream/api/tracker/application';
@@ -94,6 +95,7 @@ export class IssuesController {
private readonly refs: Refs, private readonly refs: Refs,
private readonly presence: PresenceService, private readonly presence: PresenceService,
private readonly projects: ProjectsService, private readonly projects: ProjectsService,
private readonly pins: PinsService,
) {} ) {}
@Get('me/home') @Get('me/home')
@@ -163,6 +165,41 @@ export class IssuesController {
await this.presence.leave(me.id, await this.refs.issueId(me.id, issue)); await this.presence.leave(me.id, await this.refs.issueId(me.id, issue));
} }
@Get('me/pins')
@ApiOperation({
summary: 'My pinned tickets, most recently pinned first',
description:
'Across my projects; tickets in the trash or out of my reach are left out.',
})
@ApiOkResponse({ type: [IssueDto] })
@ApiReadErrors()
async myPins(@CurrentUser() me: User) {
return this.refs.issueDtos(await this.pins.mine(me.id), viewerOf(me));
}
@Put('issues/:issue/pin')
@HttpCode(204)
@ISSUE
@ApiOperation({
summary: 'Pin a ticket: keep it at hand on my home page and in search',
description: 'Private to me; at most 20. Pinning it again changes nothing.',
})
@ApiNoContentResponse()
@ApiCommandErrors()
async pin(@CurrentUser() me: User, @Param('issue') issue: string) {
await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), true);
}
@Delete('issues/:issue/pin')
@HttpCode(204)
@ISSUE
@ApiOperation({ summary: 'Unpin a ticket' })
@ApiNoContentResponse()
@ApiCommandErrors()
async unpin(@CurrentUser() me: User, @Param('issue') issue: string) {
await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), false);
}
@Put('issues/:issue/watch') @Put('issues/:issue/watch')
@ISSUE @ISSUE
@ApiOperation({ @ApiOperation({

View File

@@ -112,6 +112,7 @@ export const APP_ICONS: Record<string, string> = {
task: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="m8.5 12 2.5 2.5 4.5-5"/></svg>', task: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="m8.5 12 2.5 2.5 4.5-5"/></svg>',
palette: palette:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a9 9 0 0 0 0 18c1.1 0 1.5-.8 1.5-1.5 0-1.2-1-1.5-1-2.5s.7-1.5 1.7-1.5H17a4 4 0 0 0 4-4c0-4.7-4-8.5-9-8.5Z"/><circle cx="7.5" cy="11" r="1"/><circle cx="10" cy="7" r="1"/><circle cx="15" cy="7.5" r="1"/></svg>', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a9 9 0 0 0 0 18c1.1 0 1.5-.8 1.5-1.5 0-1.2-1-1.5-1-2.5s.7-1.5 1.7-1.5H17a4 4 0 0 0 4-4c0-4.7-4-8.5-9-8.5Z"/><circle cx="7.5" cy="11" r="1"/><circle cx="10" cy="7" r="1"/><circle cx="15" cy="7.5" r="1"/></svg>',
pin: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4h6M10 4v5l-3 4h10l-3-4V4M12 13v7"/></svg>',
eye: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg>', eye: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg>',
'eye-off': 'eye-off':
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3l18 18M10.6 5.1A10.4 10.4 0 0 1 12 5c6.5 0 10 7 10 7a17 17 0 0 1-3.2 4.1M6.6 6.6A17 17 0 0 0 2 12s3.5 7 10 7a10 10 0 0 0 5.4-1.6M9.9 9.9a3 3 0 0 0 4.2 4.2"/></svg>', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3l18 18M10.6 5.1A10.4 10.4 0 0 1 12 5c6.5 0 10 7 10 7a17 17 0 0 1-3.2 4.1M6.6 6.6A17 17 0 0 0 2 12s3.5 7 10 7a10 10 0 0 0 5.4-1.6M9.9 9.9a3 3 0 0 0 4.2 4.2"/></svg>',

View File

@@ -43,6 +43,7 @@ export const issuePageEvents = eventGroup({
replyTo?: string | null; replyTo?: string | null;
}>(), }>(),
watchRequested: type<{ issueId: string; watching: boolean }>(), watchRequested: type<{ issueId: string; watching: boolean }>(),
pinRequested: type<{ issueId: string; pinned: boolean }>(),
trashRequested: type<{ issueId: string; trashed: boolean }>(), trashRequested: type<{ issueId: string; trashed: boolean }>(),
/** Place the ticket under another one by its key (e.g. WEB-3); null: out. */ /** Place the ticket under another one by its key (e.g. WEB-3); null: out. */
parentRequested: type<{ issueId: string; parentKey: string | null }>(), parentRequested: type<{ issueId: string; parentKey: string | null }>(),

View File

@@ -120,6 +120,13 @@ export class IssueFacade {
); );
} }
/** Pin the ticket for me (or unpin it): at hand on my home page and in search. */
setPinned(pinned: boolean): void {
this.withIssue((issueId) =>
this.dispatch.pinRequested({ issueId, pinned }),
);
}
/** Places the open ticket under another one by its key; null: back to the top. */ /** Places the open ticket under another one by its key; null: back to the top. */
/** /**
* Tickets to link to or hang the open one from: the most recently updated * Tickets to link to or hang the open one from: the most recently updated

View File

@@ -337,6 +337,20 @@ export const IssueStore = signalStore(
), ),
), ),
), ),
events
.on(issuePageEvents.pinRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
issues.pin(payload.issueId, payload.pinned),
payload.pinned
? 'Fijado: lo tienes a mano en el inicio y al buscar'
: 'Ya no está fijado',
done,
failed,
),
),
),
events events
.on(issuePageEvents.watchRequested) .on(issuePageEvents.watchRequested)
.pipe( .pipe(

View File

@@ -190,6 +190,8 @@ export interface Issue extends IssueSummary {
attachments: Attachment[]; attachments: Attachment[];
/** People following the ticket. */ /** People following the ticket. */
watchers: UserRef[]; watchers: UserRef[];
/** Whether I keep it pinned (home page and search). */
pinned: boolean;
checklist: ChecklistItem[]; checklist: ChecklistItem[];
/** Commits, branches and pull requests that mention it. */ /** Commits, branches and pull requests that mention it. */
devLinks: DevLink[]; devLinks: DevLink[];

View File

@@ -140,6 +140,10 @@ export abstract class IssuesGateway {
): Observable<CommandResult>; ): Observable<CommandResult>;
/** The signed-in person follows (or stops following) the ticket. */ /** The signed-in person follows (or stops following) the ticket. */
abstract watch(issueId: string, watching: boolean): Observable<CommandResult>; abstract watch(issueId: string, watching: boolean): Observable<CommandResult>;
/** My pinned tickets, most recently pinned first. */
abstract pins(): Observable<IssueSummary[]>;
/** Pins the ticket for me (or unpins it): private, at most 20. */
abstract pin(issueId: string, pinned: boolean): Observable<void>;
/** Tickets hanging from `issueId`. */ /** Tickets hanging from `issueId`. */
abstract children( abstract children(
projectId: string, projectId: string,

View File

@@ -43,9 +43,12 @@ import {
SetIssueParentDocument, SetIssueParentDocument,
SetIssueReleaseDocument, SetIssueReleaseDocument,
UnlinkIssuesDocument, UnlinkIssuesDocument,
UnpinIssueDocument,
UnwatchIssueDocument, UnwatchIssueDocument,
VoteIssueDocument, VoteIssueDocument,
WatchIssueDocument, WatchIssueDocument,
MyPinsDocument,
PinIssueDocument,
UpdateChecklistItemDocument, UpdateChecklistItemDocument,
UpdateIssueDocument, UpdateIssueDocument,
} from '../graphql/generated'; } from '../graphql/generated';
@@ -192,6 +195,19 @@ export class GraphqlIssuesGateway extends IssuesGateway {
.pipe(map((d) => toCommandResult(d.voteIssue))); .pipe(map((d) => toCommandResult(d.voteIssue)));
} }
pins(): Observable<IssueSummary[]> {
return this.gql
.query(MyPinsDocument, {})
.pipe(map((d) => d.myPins.map(toIssueSummary)));
}
pin(issueId: string, pinned: boolean): Observable<void> {
const done$: Observable<unknown> = pinned
? this.gql.mutate(PinIssueDocument, { issueId })
: this.gql.mutate(UnpinIssueDocument, { issueId });
return done$.pipe(map(() => undefined));
}
watch(issueId: string, watching: boolean): Observable<CommandResult> { watch(issueId: string, watching: boolean): Observable<CommandResult> {
return watching return watching
? this.gql ? this.gql

View File

@@ -120,6 +120,7 @@ fragment IssueSummaryFields on Issue {
fragment IssueFields on Issue { fragment IssueFields on Issue {
...IssueSummaryFields ...IssueSummaryFields
pinned
description description
figmaUrl figmaUrl
createdAt createdAt

View File

@@ -624,6 +624,7 @@ export type IssueSummaryFieldsFragment = {
}; };
export type IssueFieldsFragment = { export type IssueFieldsFragment = {
pinned: boolean;
description: string | null; description: string | null;
figmaUrl: string | null; figmaUrl: string | null;
createdAt: string; createdAt: string;
@@ -846,6 +847,7 @@ export type IssueQueryVariables = Exact<{
export type IssueQuery = { export type IssueQuery = {
issue: { issue: {
pinned: boolean;
description: string | null; description: string | null;
figmaUrl: string | null; figmaUrl: string | null;
createdAt: string; createdAt: string;
@@ -1012,6 +1014,52 @@ export type UnwatchIssueMutation = {
unwatchIssue: { id: string; key: string | null; version: number }; unwatchIssue: { id: string; key: string | null; version: number };
}; };
export type MyPinsQueryVariables = Exact<{ [key: string]: never }>;
export type MyPinsQuery = {
myPins: Array<{
id: string;
projectId: string;
key: string;
type: string;
area: string | null;
team: string | null;
title: string;
status: string;
statusCategory: StatusCategory;
priority: IssuePriority;
labels: Array<string>;
commentCount: number;
updatedAt: string;
version: number;
votes: number;
voted: boolean;
rank: string;
storyPoints: number | null;
startDate: string | null;
dueDate: string | null;
trashedAt: string | null;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
blockedBy: Array<{ id: string; key: string; title: string }>;
customFields: Array<{ fieldId: string; value: string }>;
}>;
};
export type PinIssueMutationVariables = Exact<{
issueId: string;
}>;
export type PinIssueMutation = { pinIssue: boolean };
export type UnpinIssueMutationVariables = Exact<{
issueId: string;
}>;
export type UnpinIssueMutation = { unpinIssue: boolean };
export type SetIssueReleaseMutationVariables = Exact<{ export type SetIssueReleaseMutationVariables = Exact<{
issueId: string; issueId: string;
releaseId?: string | null | undefined; releaseId?: string | null | undefined;
@@ -3467,6 +3515,7 @@ export const IssueFieldsFragmentDoc = {
kind: 'FragmentSpread', kind: 'FragmentSpread',
name: { kind: 'Name', value: 'IssueSummaryFields' }, name: { kind: 'Name', value: 'IssueSummaryFields' },
}, },
{ kind: 'Field', name: { kind: 'Name', value: 'pinned' } },
{ kind: 'Field', name: { kind: 'Name', value: 'description' } }, { kind: 'Field', name: { kind: 'Name', value: 'description' } },
{ kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } }, { kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
@@ -5681,6 +5730,7 @@ export const IssueDocument = {
kind: 'FragmentSpread', kind: 'FragmentSpread',
name: { kind: 'Name', value: 'IssueSummaryFields' }, name: { kind: 'Name', value: 'IssueSummaryFields' },
}, },
{ kind: 'Field', name: { kind: 'Name', value: 'pinned' } },
{ kind: 'Field', name: { kind: 'Name', value: 'description' } }, { kind: 'Field', name: { kind: 'Name', value: 'description' } },
{ kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } }, { kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
@@ -6553,6 +6603,237 @@ export const UnwatchIssueDocument = {
UnwatchIssueMutation, UnwatchIssueMutation,
UnwatchIssueMutationVariables UnwatchIssueMutationVariables
>; >;
export const MyPinsDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'MyPins' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'myPins' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'IssueSummaryFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'UserRefFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UserRef' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'IssueSummaryFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'Issue' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
{ kind: 'Field', name: { kind: 'Name', value: 'priority' } },
{ kind: 'Field', name: { kind: 'Name', value: 'labels' } },
{ kind: 'Field', name: { kind: 'Name', value: 'commentCount' } },
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'assignee' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'release' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'sprint' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'parent' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'blockedBy' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'votes' } },
{ kind: 'Field', name: { kind: 'Name', value: 'voted' } },
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'customFields' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
],
},
},
],
} as unknown as DocumentNode<MyPinsQuery, MyPinsQueryVariables>;
export const PinIssueDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'PinIssue' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'pinIssue' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'issueId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
},
],
},
],
},
},
],
} as unknown as DocumentNode<PinIssueMutation, PinIssueMutationVariables>;
export const UnpinIssueDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'UnpinIssue' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'unpinIssue' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'issueId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
},
],
},
],
},
},
],
} as unknown as DocumentNode<UnpinIssueMutation, UnpinIssueMutationVariables>;
export const SetIssueReleaseDocument = { export const SetIssueReleaseDocument = {
kind: 'Document', kind: 'Document',
definitions: [ definitions: [

View File

@@ -95,6 +95,20 @@ mutation UnwatchIssue($issueId: ID!) {
} }
} }
query MyPins {
myPins {
...IssueSummaryFields
}
}
mutation PinIssue($issueId: ID!) {
pinIssue(issueId: $issueId)
}
mutation UnpinIssue($issueId: ID!) {
unpinIssue(issueId: $issueId)
}
mutation SetIssueRelease($issueId: ID!, $releaseId: ID) { mutation SetIssueRelease($issueId: ID!, $releaseId: ID) {
setIssueRelease(issueId: $issueId, releaseId: $releaseId) { setIssueRelease(issueId: $issueId, releaseId: $releaseId) {
...CommandResultFields ...CommandResultFields

View File

@@ -205,6 +205,21 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
(execute)="facade.setWatching(!facade.watching())" (execute)="facade.setWatching(!facade.watching())"
/> />
} }
@if (project.myRole()) {
<pi-icon-button
icon="pin"
color="grey"
appearance="outline"
[text]="issue.pinned ? 'Fijado' : 'Fijar'"
[attr.aria-pressed]="issue.pinned"
[attr.title]="
issue.pinned
? 'Quitar de mis fijados'
: 'Tenerlo a mano en el inicio y al buscar'
"
(execute)="facade.setPinned(!issue.pinned)"
/>
}
@if (project.canContribute()) { @if (project.canContribute()) {
<pi-icon-button <pi-icon-button
icon="edit" icon="edit"

View File

@@ -53,6 +53,7 @@ import { ResumeComponent } from '../ui/home/resume';
import { WeekAgendaComponent } from '../ui/home/week-agenda'; import { WeekAgendaComponent } from '../ui/home/week-agenda';
import { HomeKpisComponent } from '../ui/home/kpis'; import { HomeKpisComponent } from '../ui/home/kpis';
import { MyViewsComponent } from '../ui/my-views'; import { MyViewsComponent } from '../ui/my-views';
import { PinnedComponent } from '../ui/home/pinned';
/** How often the home page refreshes its reading while open. */ /** How often the home page refreshes its reading while open. */
const REFRESH_MS = 60_000; const REFRESH_MS = 60_000;
@@ -75,6 +76,7 @@ const REFRESH_MS = 60_000;
WeekAgendaComponent, WeekAgendaComponent,
HomeKpisComponent, HomeKpisComponent,
MyViewsComponent, MyViewsComponent,
PinnedComponent,
PiActionButtonComponent, PiActionButtonComponent,
PiEmptyStateComponent, PiEmptyStateComponent,
PiSkeletonComponent, PiSkeletonComponent,
@@ -155,6 +157,7 @@ const REFRESH_MS = 60_000;
</section> </section>
@if (facade.projects().length) { @if (facade.projects().length) {
<ts-pinned />
@if (myHome.home(); as h) { @if (myHome.home(); as h) {
<ts-home-kpis <ts-home-kpis
[mine]="home.mineTotal()" [mine]="home.mineTotal()"
@@ -271,6 +274,7 @@ const REFRESH_MS = 60_000;
margin-bottom: 2rem; margin-bottom: 2rem;
} }
ts-first-steps, ts-first-steps,
ts-pinned,
ts-home-kpis, ts-home-kpis,
ts-week-agenda, ts-week-agenda,
ts-my-views { ts-my-views {

View File

@@ -30,6 +30,15 @@ import {
switchMap, switchMap,
} from 'rxjs'; } from 'rxjs';
/** A ticket as a result of the palette. */
const issueResult = (i: IssueSummary): Result => ({
id: `i:${i.id}`,
kind: 'issue',
label: `${i.key} · ${i.title}`,
detail: i.assignee?.name ?? '',
url: `/p/${i.key.slice(0, i.key.lastIndexOf('-'))}/issues/${i.key}`,
});
interface Result { interface Result {
id: string; id: string;
kind: 'project' | 'issue'; kind: 'project' | 'issue';
@@ -102,7 +111,11 @@ interface Result {
(keydown.enter)="go(result)" (keydown.enter)="go(result)"
> >
<span class="ts-palette__kind">{{ <span class="ts-palette__kind">{{
result.kind === 'project' ? 'Proyecto' : 'Ticket' result.kind === 'project'
? 'Proyecto'
: text().trim().length < 2
? 'Fijado'
: 'Ticket'
}}</span> }}</span>
<span class="ts-palette__label">{{ result.label }}</span> <span class="ts-palette__label">{{ result.label }}</span>
<span class="ts-palette__detail">{{ result.detail }}</span> <span class="ts-palette__detail">{{ result.detail }}</span>
@@ -242,6 +255,7 @@ export class CommandPalette {
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly projects = inject(ProjectsFacade); private readonly projects = inject(ProjectsFacade);
private readonly issues = inject(IssuesGateway); private readonly issues = inject(IssuesGateway);
private readonly destroyRef = inject(DestroyRef);
private readonly searchInput = private readonly searchInput =
viewChild<ElementRef<HTMLInputElement>>('search'); viewChild<ElementRef<HTMLInputElement>>('search');
@@ -254,9 +268,11 @@ export class CommandPalette {
private goTimer: ReturnType<typeof setTimeout> | null = null; private goTimer: ReturnType<typeof setTimeout> | null = null;
private readonly foundIssues = signal<IssueSummary[]>([]); private readonly foundIssues = signal<IssueSummary[]>([]);
/** Mine, shown before typing; read again each time it opens. */
protected readonly pins = signal<IssueSummary[]>([]);
protected readonly results = computed<Result[]>(() => { protected readonly results = computed<Result[]>(() => {
const query = this.text().trim().toLowerCase(); const query = this.text().trim().toLowerCase();
if (query.length < 2) return []; if (query.length < 2) return this.pins().map(issueResult);
const projects = this.projects const projects = this.projects
.sorted() .sorted()
.filter( .filter(
@@ -272,14 +288,7 @@ export class CommandPalette {
detail: p.key, detail: p.key,
url: `/p/${p.key}/board`, url: `/p/${p.key}/board`,
})); }));
const issues = this.foundIssues().map((i) => ({ return [...projects, ...this.foundIssues().map(issueResult)];
id: `i:${i.id}`,
kind: 'issue' as const,
label: `${i.key} · ${i.title}`,
detail: i.assignee?.name ?? '',
url: `/p/${i.key.slice(0, i.key.lastIndexOf('-'))}/issues/${i.key}`,
}));
return [...projects, ...issues];
}); });
constructor() { constructor() {
@@ -296,7 +305,7 @@ export class CommandPalette {
catchError(() => of([])), catchError(() => of([])),
), ),
), ),
takeUntilDestroyed(inject(DestroyRef)), takeUntilDestroyed(this.destroyRef),
) )
.subscribe((found) => this.foundIssues.set(found)); .subscribe((found) => this.foundIssues.set(found));
effect(() => { effect(() => {
@@ -349,8 +358,16 @@ export class CommandPalette {
} }
protected open(mode: 'search' | 'help'): void { protected open(mode: 'search' | 'help'): void {
if (mode === 'search' && !this.projects.sorted().length) if (mode === 'search') {
this.projects.load(); if (!this.projects.sorted().length) this.projects.load();
this.issues
.pins()
.pipe(
catchError(() => of([])),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((pins) => this.pins.set(pins));
}
this.mode.set(mode); this.mode.set(mode);
} }

View File

@@ -0,0 +1,172 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
inject,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { RouterLink } from '@angular/router';
import { PiIconComponent } from '@pi/ui';
import { ToastService } from '@task-stream/web/shared/ui';
import {
IssuesGateway,
IssueSummary,
StatusCategory,
} from '@task-stream/web/tracker/domain';
/**
* The tickets I pinned, at hand on the home page: one strip, only when
* there are some (they are pinned from the ticket's page).
*/
@Component({
selector: 'ts-pinned',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, PiIconComponent],
template: `
@if (pins().length) {
<section class="ts-pinned" aria-labelledby="pinned-title">
<h2 id="pinned-title">
<pi-icon icon="pin" size="small" />
Fijados
</h2>
<ul role="list">
@for (pin of pins(); track pin.id) {
<li [class.is-done]="pin.statusCategory === done">
<a [routerLink]="['/p', projectOf(pin.key), 'issues', pin.key]">
<span class="ts-pinned__key">{{ pin.key }}</span>
<span class="ts-pinned__title">{{ pin.title }}</span>
</a>
<button
type="button"
class="ts-pinned__unpin"
[attr.aria-label]="'Quitar ' + pin.key + ' de fijados'"
title="Quitar de fijados"
(click)="unpin(pin)"
>
×
</button>
</li>
}
</ul>
</section>
}
`,
host: { '[style.display]': "pins().length ? 'block' : 'none'" },
styles: `
.ts-pinned {
display: flex;
align-items: center;
gap: 0.75rem 1rem;
flex-wrap: wrap;
}
h2 {
display: inline-flex;
align-items: center;
gap: 0.375rem;
margin: 0;
color: var(--ts-muted);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
ul {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
li {
display: inline-flex;
align-items: center;
max-width: min(100%, 22rem);
border: 1px solid var(--ts-line);
border-radius: 999px;
background: var(--ts-surface);
&:hover,
&:focus-within {
border-color: color-mix(in srgb, var(--ts-brand) 45%, var(--ts-line));
}
&.is-done .ts-pinned__title {
color: var(--ts-muted);
text-decoration: line-through;
}
}
a {
display: inline-flex;
align-items: baseline;
gap: 0.5rem;
min-width: 0;
padding: 0.375rem 0.25rem 0.375rem 0.875rem;
color: inherit;
font-size: 0.875rem;
text-decoration: none;
}
.ts-pinned__key {
flex: none;
color: var(--ts-muted);
font-family: var(--ts-font-mono);
font-size: 0.75rem;
}
.ts-pinned__title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* A plain glyph, not @pi/ui's sized button. */
.ts-pinned__unpin {
flex: none;
width: 1.75rem !important;
min-width: 0 !important;
height: 1.75rem !important;
margin-right: 0.25rem;
padding: 0 !important;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--ts-muted);
font-size: 1.125rem;
line-height: 1;
cursor: pointer;
&:hover,
&:focus-visible {
background: var(--ts-sunken);
color: var(--ts-ink);
}
}
`,
})
export class PinnedComponent {
private readonly gateway = inject(IssuesGateway);
private readonly toast = inject(ToastService);
private readonly destroyRef = inject(DestroyRef);
protected readonly pins = signal<IssueSummary[]>([]);
protected readonly done = StatusCategory.DONE;
constructor() {
this.gateway
.pins()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ next: (pins) => this.pins.set(pins), error: () => null });
}
protected projectOf(key: string): string {
return key.slice(0, key.lastIndexOf('-'));
}
protected unpin(pin: IssueSummary): void {
this.pins.update((list) => list.filter((p) => p.id !== pin.id));
this.gateway
.pin(pin.id, false)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.toast.error(`No se pudo quitar ${pin.key} de fijados`);
this.pins.update((list) => [pin, ...list]);
},
});
}
}