diff --git a/apps/api/openapi.json b/apps/api/openapi.json index c170d9b..b32e82e 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -2776,6 +2776,283 @@ ] } }, + "/api/me/reminders": { + "get": { + "operationId": "IssuesController_myReminders", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReminderOnIssueDto" + } + } + } + } + }, + "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 waiting reminders on tickets, soonest first", + "tags": [ + "Issues" + ] + } + }, + "/api/issues/{issue}/reminder": { + "put": { + "description": "A notification (bell, push, e-mail) when the time comes. One per ticket: it replaces the waiting one. Private to me.", + "operationId": "IssuesController_setReminder", + "parameters": [ + { + "name": "issue", + "required": true, + "in": "path", + "description": "Ticket id or key", + "schema": { + "example": "WEB-12", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetReminderDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IssueReminderDto" + } + } + } + }, + "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": "Remind me of a ticket at a date and time", + "tags": [ + "Issues" + ] + }, + "delete": { + "operationId": "IssuesController_clearReminder", + "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": "Drop my reminder on a ticket", + "tags": [ + "Issues" + ] + } + }, "/api/issues/{issue}/follow-tree": { "put": { "description": "Told of its changes and of every ticket hanging from it, at any depth, also the ones added later. Private to me.", @@ -16062,6 +16339,56 @@ "at" ] }, + "IssueReminderDto": { + "type": "object", + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "note": { + "type": "string", + "nullable": true + } + }, + "required": [ + "at", + "note" + ] + }, + "ReminderOnIssueDto": { + "type": "object", + "properties": { + "reminder": { + "$ref": "#/components/schemas/IssueReminderDto" + }, + "issue": { + "$ref": "#/components/schemas/IssueDto" + } + }, + "required": [ + "reminder", + "issue" + ] + }, + "SetReminderDto": { + "type": "object", + "properties": { + "at": { + "type": "string", + "format": "date-time", + "description": "When to tell me; in the future, at most a year ahead" + }, + "note": { + "type": "string", + "maxLength": 280, + "description": "What to remember" + } + }, + "required": [ + "at" + ] + }, "IssuePageDto": { "type": "object", "properties": { diff --git a/apps/api/schema.gql b/apps/api/schema.gql index 635aa23..6602031 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -705,6 +705,9 @@ type Issue { """Linked tickets I can see""" linkedIssues: [Issue!]! links: [IssueLink!]! + + """My waiting reminder on it (null without one or a token)""" + myReminder: IssueReminder number: Int! """The epic or ticket this one breaks down""" @@ -845,6 +848,13 @@ enum IssuePriority { MEDIUM } +"""My own reminder on a ticket («remind me on Thursday»)""" +type IssueReminder { + """When it tells me (ISO, UTC)""" + at: String! + note: String +} + """RECENT: last updated first. RANK: backlog order.""" enum IssueSort { RANK @@ -971,6 +981,9 @@ type Mutation { changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult! changeProjectMemberRole(input: ProjectMemberInput!): CommandResult! + """Drop my reminder on a ticket""" + clearReminder(issueId: ID!): Boolean! + """ Close the sprint under way. Unfinished tickets move to carryOverToSprintId, or back to the backlog when it is null. """ @@ -1103,6 +1116,16 @@ type Mutation { """Put a ticket in a sprint (null: back to the backlog)""" setIssueSprint(issueId: ID!, sprintId: ID): CommandResult! + """ + Remind me of a ticket at a date and time (a notification), with an optional note. One per ticket: it replaces the waiting one + """ + setReminder( + """ISO date-time, in the future""" + at: String! + issueId: ID! + note: String + ): IssueReminder! + """Turn the link on or change its blocks; renew for a new secret""" shareDashboard(input: ShareDashboardInput!): DashboardShare! @@ -1645,6 +1668,9 @@ type Query { """Projects where I am a member""" myProjects: [Project!]! + """My waiting reminders on tickets, soonest first""" + myReminders: [ReminderOnIssue!]! + """My saved views of my work, across my projects""" myViews: [MyView!]! notificationSettings: NotificationSettings! @@ -1779,6 +1805,12 @@ enum ReleaseStatus { RELEASED } +"""One of my waiting reminders, with its ticket""" +type ReminderOnIssue { + issue: Issue! + reminder: IssueReminder! +} + """Tickets to look at now (worst first)""" type Risks { blocked: [InsightIssue!]! diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 15a545d..221b22b 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -502,6 +502,44 @@ export interface paths { patch?: never; trace?: never; }; + "/api/me/reminders": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** My waiting reminders on tickets, soonest first */ + get: operations["IssuesController_myReminders"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/issues/{issue}/reminder": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Remind me of a ticket at a date and time + * @description A notification (bell, push, e-mail) when the time comes. One per ticket: it replaces the waiting one. Private to me. + */ + put: operations["IssuesController_setReminder"]; + post?: never; + /** Drop my reminder on a ticket */ + delete: operations["IssuesController_clearReminder"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/issues/{issue}/follow-tree": { parameters: { query?: never; @@ -2913,6 +2951,24 @@ export interface components { */ at: string; }; + IssueReminderDto: { + /** Format: date-time */ + at: string; + note: string | null; + }; + ReminderOnIssueDto: { + reminder: components["schemas"]["IssueReminderDto"]; + issue: components["schemas"]["IssueDto"]; + }; + SetReminderDto: { + /** + * Format: date-time + * @description When to tell me; in the future, at most a year ahead + */ + at: string; + /** @description What to remember */ + note?: string; + }; IssuePageDto: { items: components["schemas"]["IssueDto"][]; /** @description Total matches (for pagination) */ @@ -5753,6 +5809,215 @@ export interface operations { }; }; }; + IssuesController_myReminders: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReminderOnIssueDto"][]; + }; + }; + /** @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_setReminder: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetReminderDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IssueReminderDto"]; + }; + }; + /** @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_clearReminder: { + 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_followTree: { parameters: { query?: never; diff --git a/apps/mcp/src/app/tools/issues.resolver.ts b/apps/mcp/src/app/tools/issues.resolver.ts index 753e889..e26385b 100644 --- a/apps/mcp/src/app/tools/issues.resolver.ts +++ b/apps/mcp/src/app/tools/issues.resolver.ts @@ -783,6 +783,41 @@ export class IssuesResolver { ); } + @Tool({ + name: 'remind_me', + description: + 'Remind me of a ticket at a date and time (a notification), with an optional note; one per ticket, it replaces the waiting one. clear: true drops it. Times are ISO with offset, e.g. 2026-10-23T09:00:00+02:00.', + paramsSchema: z.object({ + issue: issueRef, + at: z.string().optional(), + note: z.string().max(280).optional(), + clear: z.boolean().default(false), + }), + annotations: { ...WRITE, idempotentHint: true }, + }) + remindMe( + params: { issue: string; at?: string; note?: string; clear: boolean }, + ctx: McpContext, + ) { + const client = this.api.as(ctx); + const path = { issue: params.issue }; + return toolResult(async () => { + if (params.clear) { + await this.api.data( + client.DELETE('/api/issues/{issue}/reminder', { params: { path } }), + ); + return { issue: params.issue, reminder: null }; + } + if (!params.at) throw new Error('at is required unless clear is true'); + return this.api.data( + client.PUT('/api/issues/{issue}/reminder', { + params: { path }, + body: { at: params.at, note: params.note }, + }), + ); + }); + } + @Tool({ name: 'resolve_thread', description: diff --git a/docs/03-api.md b/docs/03-api.md index 1f403a2..cbaf4a1 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -425,7 +425,12 @@ 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, 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 })`, -`?watching=true`; sin sesión, ninguno). **Seguir todo**: seguir un ticket con todo lo que cuelga de él (una épica, un ticket con subtareas): +`?watching=true`; sin sesión, ninguno). **Recordármelo**: cada persona pone en un ticket un recordatorio con fecha y hora y nota opcional +(`setReminder(issueId, at, note)`, `clearReminder`, `Issue.myReminder`, `myReminders`; `PUT/DELETE +/api/issues/{issue}/reminder`, `GET /api/me/reminders`): uno por ticket, en el futuro y como mucho a un año. Cada minuto +`IssueReminderScheduler` avisa de los que tocan (`REMINDER`, por la campana, el navegador y el correo; una vez, por su +id) y olvida los de tickets en la papelera o fuera de alcance. Privados y sin historial (`issue_reminders`); +`REMINDERS_ENABLED=false` los apaga junto con los de fecha límite. **Seguir todo**: seguir un ticket con todo lo que cuelga de él (una épica, un ticket con subtareas): `followTree`/`unfollowTree`, `Issue.followingTree`; `PUT/DELETE /api/issues/{issue}/follow-tree`. Quien lo hace oye lo mismo que un seguidor de cada ticket de debajo, a cualquier profundidad, también de los que se añadan, y además cuándo uno entra (`IssueParentChangedEvent`). Personal y sin historial (colección `tree_follows`); los invitados de un proyecto @@ -544,17 +549,17 @@ que funcionan también sin token. Todas devuelven `CommandResult { id, key, version }`. -| Ámbito | Mutations | -| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` | -| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `pinIssue`, `unpinIssue`, `followTree`, `unfollowTree` (devuelven `true`), `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` | -| Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`, `resolveThread(commentId, resolved, conclusion)`, `typing(issueId, threadId)`; consulta `decisions(projectId, search)` | -| 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)`, `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` | +| Ámbito | Mutations | +| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` | +| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `pinIssue`, `unpinIssue`, `followTree`, `unfollowTree`, `clearReminder` (devuelven `true`), `setReminder`, `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` | +| Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`, `resolveThread(commentId, resolved, conclusion)`, `typing(issueId, threadId)`; consulta `decisions(projectId, search)` | +| 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)`, `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` | ### Subscriptions @@ -752,25 +757,25 @@ 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`), `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 …/follow-tree`, `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`, `PUT/DELETE …/decision`, `PUT/DELETE …/resolution`; `GET /api/issues/{issue}/decisions`, `GET /api/projects/{project}/decisions`, `GET /api/me/decisions` | -| 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) | -| Compartir | `GET/PUT/DELETE /api/projects/{project}/share` (enlace para clientes), `GET /api/shared/dashboards/{token}` (lo que enseña, 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) | -| Alertas | `GET/PUT/DELETE /api/projects/{project}/alert-integration`, `POST …/alert-integration/token`, `POST /api/webhooks/alerts/{project}/{token}` | -| 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`), `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 …/follow-tree`, `PUT/DELETE …/reminder`, `GET /api/me/reminders`, `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`, `PUT/DELETE …/decision`, `PUT/DELETE …/resolution`; `GET /api/issues/{issue}/decisions`, `GET /api/projects/{project}/decisions`, `GET /api/me/decisions` | +| 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) | +| Compartir | `GET/PUT/DELETE /api/projects/{project}/share` (enlace para clientes), `GET /api/shared/dashboards/{token}` (lo que enseña, 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) | +| Alertas | `GET/PUT/DELETE /api/projects/{project}/alert-integration`, `POST …/alert-integration/token`, `POST /api/webhooks/alerts/{project}/{token}` | +| 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 4f68b17..08fae68 100644 --- a/docs/04-cliente-web.md +++ b/docs/04-cliente-web.md @@ -300,25 +300,25 @@ visualización de la plataforma: ## 4.6 Rutas -| Ruta | Página | -| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/login` | Inicio de sesión (solo sin sesión) | -| `/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), 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 | -| `/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). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar | -| `/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. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea). Si tiene subtareas, «Seguir todo» avisa de ella y de todo lo que cuelga, también lo que se añada (también desde Épicas) | -| `/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/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/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo | -| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) | -| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, alertas → bugs con su URL secreta y cómo conectar Sentry, Uptime Kuma o Grafana, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar | -| `/p/:key/activity` | Actividad en vivo | -| `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» | -| `/share/:token` | Panel para clientes, sin sesión: los bloques que eligió el proyecto (flujo, sprint, releases, épicas, actividad), sin enlaces hacia dentro de la aplicación; se vuelve a leer cada minuto. Si el enlace se renovó o retiró, «Este enlace ya no funciona» | -| `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos | +| Ruta | Página | +| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/login` | Inicio de sesión (solo sin sesión) | +| `/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), 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 | +| `/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). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar | +| `/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. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea). Si tiene subtareas, «Seguir todo» avisa de ella y de todo lo que cuelga, también lo que se añada (también desde Épicas). «Recordármelo» pone un aviso solo para mí (dentro de 1 hora, esta tarde, mañana o el lunes a las 9, en una semana o cuando diga), con nota opcional | +| `/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/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/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo | +| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) | +| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, alertas → bugs con su URL secreta y cómo conectar Sentry, Uptime Kuma o Grafana, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar | +| `/p/:key/activity` | Actividad en vivo | +| `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» | +| `/share/:token` | Panel para clientes, sin sesión: los bloques que eligió el proyecto (flujo, sprint, releases, épicas, actividad), sin enlaces hacia dentro de la aplicación; se vuelve a leer cada minuto. Si el enlace se renovó o retiró, «Este enlace ya no funciona» | +| `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos | Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien no tiene sesión, y el resto se puede abrir sin ella si el proyecto es público. Los parámetros de ruta llegan como inputs de diff --git a/docs/09-mcp.md b/docs/09-mcp.md index b68f88a..ac081cc 100644 --- a/docs/09-mcp.md +++ b/docs/09-mcp.md @@ -68,6 +68,7 @@ flowchart LR | | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción | | | `mark_decision` | Marcar un comentario como decisión del ticket, o quitarla | | | `resolve_thread` | Resolver una conversación (con conclusión opcional, que queda como decisión) o reabrirla | +| | `remind_me` | Recordármelo: un aviso solo para mí sobre un ticket, a una fecha y hora, con nota (o quitarlo) | | | `search_decisions` | Qué se decidió: decisiones por su texto, de un proyecto o de todos los míos | | | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar | | Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una | diff --git a/libs/api/tracker/application/src/index.ts b/libs/api/tracker/application/src/index.ts index fa0b064..0c6f590 100644 --- a/libs/api/tracker/application/src/index.ts +++ b/libs/api/tracker/application/src/index.ts @@ -43,6 +43,9 @@ export * from './lib/alerts.service'; export * from './lib/follows/tree-follows'; export * from './lib/ports/tree-follows.repository'; export * from './lib/tree-follows.service'; +export * from './lib/reminders/issue-reminders'; +export * from './lib/ports/issue-reminders.repository'; +export * from './lib/issue-reminders.service'; export * from './lib/tracker-application.module'; export * from './lib/ports/presence.store'; export * from './lib/presence.service'; diff --git a/libs/api/tracker/application/src/lib/issue-reminders.service.ts b/libs/api/tracker/application/src/lib/issue-reminders.service.ts new file mode 100644 index 0000000..13e06d0 --- /dev/null +++ b/libs/api/tracker/application/src/lib/issue-reminders.service.ts @@ -0,0 +1,53 @@ +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 { IssueReminder } from './ports/issue-reminders.repository'; +import { + ClearIssueReminderCommand, + GetIssueReminderQuery, + GetMyRemindersQuery, + SendIssueRemindersCommand, + SetIssueReminderCommand, +} from './reminders/issue-reminders'; + +/** «Remind me on Thursday»: one's own reminders on tickets. */ +@Injectable() +export class IssueRemindersService { + constructor( + private readonly commands: CommandRunner, + private readonly queryBus: QueryBus, + ) {} + + set( + userId: string, + issueId: string, + at: string, + note: string | null, + ): Promise { + return this.commands.execute( + new SetIssueReminderCommand(userId, issueId, at, note), + ); + } + + clear(userId: string, issueId: string): Promise { + return this.commands.execute( + new ClearIssueReminderCommand(userId, issueId), + ); + } + + of(userId: string, issueId: string): Promise { + return this.queryBus.execute(new GetIssueReminderQuery(userId, issueId)); + } + + mine( + userId: string, + ): Promise<{ reminder: IssueReminder; issue: IssueReadModel }[]> { + return this.queryBus.execute(new GetMyRemindersQuery(userId)); + } + + /** Scheduler only. */ + sendDue(): Promise { + return this.commands.execute(new SendIssueRemindersCommand()); + } +} diff --git a/libs/api/tracker/application/src/lib/ports/issue-reminders.repository.ts b/libs/api/tracker/application/src/lib/ports/issue-reminders.repository.ts new file mode 100644 index 0000000..6ef27b5 --- /dev/null +++ b/libs/api/tracker/application/src/lib/ports/issue-reminders.repository.ts @@ -0,0 +1,29 @@ +/** «Remind me on Thursday»: one person's, on one ticket. */ +export interface IssueReminder { + id: string; + userId: string; + issueId: string; + projectId: string; + /** When to tell (ISO, UTC). */ + at: string; + /** What to remember, in one line. */ + note: string | null; + createdAt: string; + /** When it was told (null: still waiting). */ + sentAt: string | null; +} + +export abstract class IssueRemindersRepository { + /** Each person has at most one waiting per ticket: setting it replaces it. */ + abstract save(reminder: IssueReminder): Promise; + abstract waitingFor( + userId: string, + issueId: string, + ): Promise; + /** One person's waiting reminders, soonest first. */ + abstract waitingOf(userId: string): Promise; + abstract remove(userId: string, issueId: string): Promise; + /** Waiting ones whose time has come, oldest first. */ + abstract due(now: string, limit: number): Promise; + abstract markSent(id: string, at: string): Promise; +} diff --git a/libs/api/tracker/application/src/lib/reminders/issue-reminders.spec.ts b/libs/api/tracker/application/src/lib/reminders/issue-reminders.spec.ts new file mode 100644 index 0000000..7df0f22 --- /dev/null +++ b/libs/api/tracker/application/src/lib/reminders/issue-reminders.spec.ts @@ -0,0 +1,171 @@ +import { + CommandBus, + CqrsModule, + IQueryHandler, + QueryBus, + QueryHandler, +} from '@nestjs/cqrs'; +import { Test } from '@nestjs/testing'; +import { NotFoundError, ValidationError } from '@task-stream/api/shared/domain'; +import { + IssueReadModel, + NotificationReadModel, +} from '@task-stream/api/tracker/domain'; +import { Notifier } from '../notifications/notifier'; +import { + IssueReminder, + IssueRemindersRepository, +} from '../ports/issue-reminders.repository'; +import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query'; +import { + GetMyRemindersQuery, + ISSUE_REMINDERS, + SendIssueRemindersCommand, + SetIssueReminderCommand, +} from './issue-reminders'; + +class InMemoryReminders extends IssueRemindersRepository { + rows: IssueReminder[] = []; + async save(reminder: IssueReminder) { + await this.remove(reminder.userId, reminder.issueId); + this.rows.push(reminder); + } + async waitingFor(userId: string, issueId: string) { + return ( + this.rows.find( + (r) => r.userId === userId && r.issueId === issueId && !r.sentAt, + ) ?? null + ); + } + async waitingOf(userId: string) { + return this.rows + .filter((r) => r.userId === userId && !r.sentAt) + .sort((a, b) => a.at.localeCompare(b.at)); + } + async remove(userId: string, issueId: string) { + this.rows = this.rows.filter( + (r) => !(r.userId === userId && r.issueId === issueId && !r.sentAt), + ); + } + async due(now: string) { + return this.rows.filter((r) => !r.sentAt && r.at <= now); + } + async markSent(id: string, at: string) { + const row = this.rows.find((r) => r.id === id); + if (row) row.sentAt = at; + } +} + +/** Ana reads i1 and i2; i2 goes to the trash in one test. */ +const issues = new Map(); + +@QueryHandler(GetIssuesByIdsQuery) +class FakeIssuesByIds implements IQueryHandler { + async execute(query: GetIssuesByIdsQuery) { + return query.viewerId === 'ana' + ? query.ids.flatMap((id) => issues.get(id) ?? []) + : []; + } +} + +const inHours = (h: number) => + new Date(Date.now() + h * 3_600_000).toISOString(); + +async function setup() { + issues.clear(); + for (const id of ['i1', 'i2']) + issues.set(id, { + id, + key: id === 'i1' ? 'WEB-1' : 'WEB-2', + title: 'Pagos', + projectId: 'p1', + projectKey: 'WEB', + trashedAt: null, + } as IssueReadModel); + const rows = new InMemoryReminders(); + const sent: NotificationReadModel[] = []; + const moduleRef = await Test.createTestingModule({ + imports: [CqrsModule.forRoot()], + providers: [ + ...ISSUE_REMINDERS, + FakeIssuesByIds, + { provide: IssueRemindersRepository, useValue: rows }, + { + provide: Notifier, + useValue: { + send: async (n: NotificationReadModel[]) => void sent.push(...n), + }, + }, + ], + }).compile(); + await moduleRef.init(); + return { + rows, + sent, + commands: moduleRef.get(CommandBus), + queries: moduleRef.get(QueryBus), + }; +} + +describe('ticket reminders', () => { + it('keeps one per person and ticket, in the future and within a year', async () => { + const { commands, queries } = await setup(); + await commands.execute( + new SetIssueReminderCommand('ana', 'i1', inHours(2)), + ); + await commands.execute( + new SetIssueReminderCommand('ana', 'i1', inHours(5), ' Llamar a QA '), + ); + await commands.execute( + new SetIssueReminderCommand('ana', 'i2', inHours(1)), + ); + const mine = await queries.execute(new GetMyRemindersQuery('ana')); + expect(mine.map((m: { issue: IssueReadModel }) => m.issue.key)).toEqual([ + 'WEB-2', + 'WEB-1', + ]); + expect(mine[1].reminder.note).toBe('Llamar a QA'); + await expect( + commands.execute(new SetIssueReminderCommand('ana', 'i1', inHours(-2))), + ).rejects.toBeInstanceOf(ValidationError); + await expect( + commands.execute( + new SetIssueReminderCommand('ana', 'i1', inHours(24 * 400)), + ), + ).rejects.toBeInstanceOf(ValidationError); + await expect( + commands.execute(new SetIssueReminderCommand('eve', 'i1', inHours(2))), + ).rejects.toBeInstanceOf(NotFoundError); + }); + + it('tells the ones whose time came, once, and forgets those out of reach', async () => { + const { commands, rows, sent } = await setup(); + await commands.execute( + new SetIssueReminderCommand('ana', 'i1', inHours(1), 'Revisar el PR'), + ); + await commands.execute( + new SetIssueReminderCommand('ana', 'i2', inHours(1)), + ); + await commands.execute( + new SetIssueReminderCommand('ana', 'i1', inHours(1)), + ); + expect(await commands.execute(new SendIssueRemindersCommand())).toBe(0); + + issues.set('i2', { + ...(issues.get('i2') as IssueReadModel), + trashedAt: 'x', + }); + const later = inHours(2); + expect(await commands.execute(new SendIssueRemindersCommand(later))).toBe( + 1, + ); + expect(sent.map((n) => [n.userId, n.issueKey, n.summary])).toEqual([ + ['ana', 'WEB-1', 'Recordatorio: WEB-1 · Pagos'], + ]); + expect(rows.rows.every((r) => r.sentAt)).toBe(true); + // Run again: nothing left. + expect(await commands.execute(new SendIssueRemindersCommand(later))).toBe( + 0, + ); + }); +}); diff --git a/libs/api/tracker/application/src/lib/reminders/issue-reminders.ts b/libs/api/tracker/application/src/lib/reminders/issue-reminders.ts new file mode 100644 index 0000000..ca3948b --- /dev/null +++ b/libs/api/tracker/application/src/lib/reminders/issue-reminders.ts @@ -0,0 +1,232 @@ +import { randomUUID } from 'node:crypto'; +import { + Command, + CommandHandler, + ICommandHandler, + IQueryHandler, + Query, + QueryBus, + QueryHandler, +} from '@nestjs/cqrs'; +import { assertThat, NotFoundError } from '@task-stream/api/shared/domain'; +import { + IssueReadModel, + NotificationKind, + SYSTEM_ACTOR_ID, + SYSTEM_ACTOR_NAME, +} from '@task-stream/api/tracker/domain'; +import { Notifier } from '../notifications/notifier'; +import { + IssueReminder, + IssueRemindersRepository, +} from '../ports/issue-reminders.repository'; +import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query'; + +/* + * «Remind me on Thursday»: a person's own reminder on a ticket, told as a + * notification (bell, push, e-mail) when its time comes. One waiting per + * person and ticket; private, no history. + */ + +/** As far ahead as a reminder goes. */ +export const MAX_REMINDER_DAYS = 366; +export const MAX_REMINDER_NOTE = 280; + +export class SetIssueReminderCommand extends Command { + constructor( + public readonly userId: string, + public readonly issueId: string, + /** ISO date-time. */ + public readonly at: string, + public readonly note: string | null = null, + ) { + super(); + } +} + +export class ClearIssueReminderCommand extends Command { + constructor( + public readonly userId: string, + public readonly issueId: string, + ) { + super(); + } +} + +/** Scheduler only: tells the reminders whose time has come. */ +export class SendIssueRemindersCommand extends Command { + constructor(public readonly now: string = new Date().toISOString()) { + super(); + } +} + +export class GetIssueReminderQuery extends Query { + constructor( + public readonly userId: string, + public readonly issueId: string, + ) { + super(); + } +} + +/** My waiting reminders with their tickets, soonest first. */ +export class GetMyRemindersQuery extends Query< + { reminder: IssueReminder; issue: IssueReadModel }[] +> { + constructor(public readonly userId: string) { + super(); + } +} + +@CommandHandler(SetIssueReminderCommand) +export class SetIssueReminderCommandHandler + implements ICommandHandler +{ + constructor( + private readonly reminders: IssueRemindersRepository, + private readonly queryBus: QueryBus, + ) {} + + async execute(command: SetIssueReminderCommand): Promise { + const at = Date.parse(command.at); + const now = Date.now(); + assertThat(!Number.isNaN(at), 'A reminder needs a date and time'); + assertThat(at > now - 60_000, 'A reminder must be in the future'); + assertThat( + at <= now + MAX_REMINDER_DAYS * 86_400_000, + `A reminder can be at most a year ahead`, + ); + const note = command.note?.replace(/\s+/g, ' ').trim() || null; + assertThat( + !note || note.length <= MAX_REMINDER_NOTE, + `A reminder's note takes ${MAX_REMINDER_NOTE} characters at most`, + ); + const [issue]: IssueReadModel[] = await this.queryBus.execute( + new GetIssuesByIdsQuery(command.userId, [command.issueId]), + ); + // A ticket I cannot read does not exist, as far as I know. + if (!issue || issue.trashedAt) + throw new NotFoundError('Issue', command.issueId); + const reminder: IssueReminder = { + id: randomUUID(), + userId: command.userId, + issueId: issue.id, + projectId: issue.projectId, + at: new Date(at).toISOString(), + note, + createdAt: new Date(now).toISOString(), + sentAt: null, + }; + await this.reminders.save(reminder); + return reminder; + } +} + +@CommandHandler(ClearIssueReminderCommand) +export class ClearIssueReminderCommandHandler + implements ICommandHandler +{ + constructor(private readonly reminders: IssueRemindersRepository) {} + + execute(command: ClearIssueReminderCommand): Promise { + return this.reminders.remove(command.userId, command.issueId); + } +} + +@CommandHandler(SendIssueRemindersCommand) +export class SendIssueRemindersCommandHandler + implements ICommandHandler +{ + constructor( + private readonly reminders: IssueRemindersRepository, + private readonly queryBus: QueryBus, + private readonly notifier: Notifier, + ) {} + + async execute(command: SendIssueRemindersCommand): Promise { + const due = await this.reminders.due(command.now, 200); + let sent = 0; + for (const reminder of due) { + const [issue]: IssueReadModel[] = await this.queryBus.execute( + new GetIssuesByIdsQuery(reminder.userId, [reminder.issueId]), + ); + // Out of reach or in the trash by now: nothing to remind of. + if (issue && !issue.trashedAt) { + // Its id makes it idempotent: two instances tell it once. + await this.notifier.send([ + { + id: `issue-reminder:${reminder.id}`, + userId: reminder.userId, + kind: NotificationKind.REMINDER, + projectId: issue.projectId, + projectKey: issue.projectKey, + issueId: issue.id, + issueKey: issue.key, + actor: { id: SYSTEM_ACTOR_ID, name: SYSTEM_ACTOR_NAME }, + summary: reminder.note + ? `Recordatorio de ${issue.key}: ${reminder.note}` + : `Recordatorio: ${issue.key} · ${issue.title}`, + readAt: null, + createdAt: command.now, + }, + ]); + sent++; + } + await this.reminders.markSent(reminder.id, command.now); + } + return sent; + } +} + +@QueryHandler(GetIssueReminderQuery) +export class GetIssueReminderQueryHandler + implements IQueryHandler +{ + constructor(private readonly reminders: IssueRemindersRepository) {} + + execute(query: GetIssueReminderQuery): Promise { + return this.reminders.waitingFor(query.userId, query.issueId); + } +} + +@QueryHandler(GetMyRemindersQuery) +export class GetMyRemindersQueryHandler + implements + IQueryHandler< + GetMyRemindersQuery, + { reminder: IssueReminder; issue: IssueReadModel }[] + > +{ + constructor( + private readonly reminders: IssueRemindersRepository, + private readonly queryBus: QueryBus, + ) {} + + async execute( + query: GetMyRemindersQuery, + ): Promise<{ reminder: IssueReminder; issue: IssueReadModel }[]> { + const waiting = await this.reminders.waitingOf(query.userId); + if (!waiting.length) return []; + const issues: IssueReadModel[] = await this.queryBus.execute( + new GetIssuesByIdsQuery( + query.userId, + waiting.map((r) => r.issueId), + ), + ); + const byId = new Map( + issues.filter((i) => !i.trashedAt).map((i) => [i.id, i]), + ); + return waiting.flatMap((reminder) => { + const issue = byId.get(reminder.issueId); + return issue ? [{ reminder, issue }] : []; + }); + } +} + +export const ISSUE_REMINDERS = [ + SetIssueReminderCommandHandler, + ClearIssueReminderCommandHandler, + SendIssueRemindersCommandHandler, + GetIssueReminderQueryHandler, + GetMyRemindersQueryHandler, +]; 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 bab5563..199df64 100644 --- a/libs/api/tracker/application/src/lib/tracker-application.module.ts +++ b/libs/api/tracker/application/src/lib/tracker-application.module.ts @@ -38,6 +38,8 @@ import { MY_VIEWS } from './saved-filters/my-views'; import { PINS } from './saved-filters/pins'; import { ALERT_HANDLERS } from './alerts/alerts'; import { TREE_FOLLOWS } from './follows/tree-follows'; +import { ISSUE_REMINDERS } from './reminders/issue-reminders'; +import { IssueRemindersService } from './issue-reminders.service'; import { TreeFollowsService } from './tree-follows.service'; import { AlertsService } from './alerts.service'; import { PinsService } from './pins.service'; @@ -87,6 +89,7 @@ const SERVICES = [ PinsService, AlertsService, TreeFollowsService, + IssueRemindersService, SprintsService, GitService, WebhooksService, @@ -120,6 +123,7 @@ const SERVICES = [ ...PINS, ...ALERT_HANDLERS, ...TREE_FOLLOWS, + ...ISSUE_REMINDERS, ...GIT_HANDLERS, ...WEBHOOK_HANDLERS, ...TRANSFER_HANDLERS, diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/issue-reminders.repository.ts b/libs/api/tracker/infrastructure/src/lib/mongo/issue-reminders.repository.ts new file mode 100644 index 0000000..2da6a22 --- /dev/null +++ b/libs/api/tracker/infrastructure/src/lib/mongo/issue-reminders.repository.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { + IssueReminder, + IssueRemindersRepository, +} from '@task-stream/api/tracker/application'; +import { Model } from 'mongoose'; +import { ReadModels } from './read-model.schemas'; + +type Stored = IssueReminder & { _id?: unknown }; + +const strip = ({ _id: _ignored, ...reminder }: Stored): IssueReminder => + reminder; + +@Injectable() +export class MongoIssueRemindersRepository extends IssueRemindersRepository { + constructor( + @InjectModel(ReadModels.IssueReminder) + private readonly reminders: Model, + ) { + super(); + } + + async save(reminder: IssueReminder): Promise { + await this.remove(reminder.userId, reminder.issueId); + await this.reminders.create(reminder); + } + + async waitingFor( + userId: string, + issueId: string, + ): Promise { + const doc = await this.reminders + .findOne({ userId, issueId, sentAt: null }) + .lean(); + return doc ? strip(doc) : null; + } + + async waitingOf(userId: string): Promise { + const docs = await this.reminders + .find({ userId, sentAt: null }) + .sort({ at: 1 }) + .lean(); + return docs.map(strip); + } + + async remove(userId: string, issueId: string): Promise { + await this.reminders.deleteMany({ userId, issueId, sentAt: null }); + } + + async due(now: string, limit: number): Promise { + const docs = await this.reminders + .find({ sentAt: null, at: { $lte: now } }) + .sort({ at: 1 }) + .limit(limit) + .lean(); + return docs.map(strip); + } + + async markSent(id: string, at: string): Promise { + await this.reminders.updateOne({ id }, { $set: { sentAt: at } }); + } +} 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 ecb47ae..64318fd 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 @@ -110,6 +110,7 @@ export const ReadModels = { AlertIntegration: 'AlertIntegration', AlertTrace: 'AlertTrace', TreeFollow: 'TreeFollow', + IssueReminder: 'IssueReminder', Sprint: 'SprintReadModel', GitIntegration: 'GitIntegration', OutgoingWebhook: 'OutgoingWebhook', @@ -370,6 +371,20 @@ export const TreeFollowSchema = SchemaFactory.createForClass(TreeFollowDocument); TreeFollowSchema.index({ userId: 1, issueId: 1 }, { unique: true }); +@Schema({ ...LOOSE, collection: 'issue_reminders' }) +class IssueReminderDocument { + @Prop({ type: String, required: true, unique: true }) id!: string; + @Prop({ type: String }) userId!: string; + @Prop({ type: String }) issueId!: string; + @Prop({ type: String }) at!: string; + @Prop({ type: String, default: null }) sentAt!: string | null; +} +export const IssueReminderSchema = SchemaFactory.createForClass( + IssueReminderDocument, +); +IssueReminderSchema.index({ sentAt: 1, at: 1 }); +IssueReminderSchema.index({ userId: 1, issueId: 1 }); + @Schema({ ...LOOSE, collection: 'git_integrations' }) class GitIntegrationDocument { /** The project's id. */ @@ -476,6 +491,7 @@ export const READ_MODEL_SCHEMAS = [ { name: ReadModels.AlertIntegration, schema: AlertIntegrationSchema }, { name: ReadModels.AlertTrace, schema: AlertTraceSchema }, { name: ReadModels.TreeFollow, schema: TreeFollowSchema }, + { name: ReadModels.IssueReminder, schema: IssueReminderSchema }, { name: ReadModels.CalendarFeed, schema: CalendarFeedSchema }, { name: ReadModels.DashboardShare, schema: DashboardShareSchema }, { 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 bd65809..fc053a4 100644 --- a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts +++ b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts @@ -16,6 +16,7 @@ import { PinsRepository, AlertIntegrationsRepository, TreeFollowsRepository, + IssueRemindersRepository, GitIntegrationsRepository, OutgoingWebhooksRepository, AutomationsRepository, @@ -84,6 +85,7 @@ import { MongoMyViewsRepository } from './mongo/my-views.repository'; import { MongoPinsRepository } from './mongo/pins.repository'; import { MongoAlertIntegrationsRepository } from './mongo/alert-integrations.repository'; import { MongoTreeFollowsRepository } from './mongo/tree-follows.repository'; +import { MongoIssueRemindersRepository } from './mongo/issue-reminders.repository'; import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository'; import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository'; import { HttpWebhookSender } from './webhooks/http-webhook.sender'; @@ -166,6 +168,10 @@ const PORTS = [ useClass: MongoAlertIntegrationsRepository, }, { provide: TreeFollowsRepository, useClass: MongoTreeFollowsRepository }, + { + provide: IssueRemindersRepository, + useClass: MongoIssueRemindersRepository, + }, { provide: GitIntegrationsRepository, useClass: MongoGitIntegrationsRepository, 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 7b0d0b7..dd195a4 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts @@ -19,6 +19,7 @@ import { IssuesService, PinsService, TreeFollowsService, + IssueRemindersService, PresenceService, WorkflowsService, } from '@task-stream/api/tracker/application'; @@ -52,6 +53,8 @@ import { IssueGql, IssuePageGql, IssueParentGql, + IssueReminderGql, + ReminderOnIssueGql, PresenceGql, } from './types'; @@ -65,6 +68,7 @@ export class IssueResolver { private readonly presence: PresenceService, private readonly pins: PinsService, private readonly treeFollows: TreeFollowsService, + private readonly reminders: IssueRemindersService, ) {} @Mutation(() => [PresenceGql], { @@ -282,6 +286,17 @@ export class IssueResolver { return this.commentsService.decisions(viewerOf(me), issue.id); } + @ResolveField(() => IssueReminderGql, { + nullable: true, + description: 'My waiting reminder on it (null without one or a token)', + }) + myReminder( + @CurrentUser() me: User | undefined, + @Parent() issue: IssueReadModel, + ) { + return me ? this.reminders.of(me.id, issue.id) : null; + } + @ResolveField(() => Boolean, { description: 'Whether I follow it with everything under it (false without a token)', @@ -427,6 +442,35 @@ export class IssueResolver { return true; } + @Query(() => [ReminderOnIssueGql], { + description: 'My waiting reminders on tickets, soonest first', + }) + myReminders(@CurrentUser() me: User) { + return this.reminders.mine(me.id); + } + + @Mutation(() => IssueReminderGql, { + description: + 'Remind me of a ticket at a date and time (a notification), with an optional note. One per ticket: it replaces the waiting one', + }) + setReminder( + @CurrentUser() me: User, + @Args('issueId', { type: () => ID }) issueId: string, + @Args('at', { description: 'ISO date-time, in the future' }) at: string, + @Args('note', { nullable: true }) note: string | null, + ) { + return this.reminders.set(me.id, issueId, at, note ?? null); + } + + @Mutation(() => Boolean, { description: 'Drop my reminder on a ticket' }) + async clearReminder( + @CurrentUser() me: User, + @Args('issueId', { type: () => ID }) issueId: string, + ): Promise { + await this.reminders.clear(me.id, issueId); + return true; + } + @Mutation(() => CommandResultGql, { description: 'Stop following a ticket' }) unwatchIssue( @CurrentUser() me: User, diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index 39d2989..21e58ca 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -1377,3 +1377,19 @@ export class AlertIntegrationGql { typeId!: string | null; @Field(() => IssuePriority) priority!: IssuePriority; } + +@ObjectType('IssueReminder', { + description: 'My own reminder on a ticket («remind me on Thursday»)', +}) +export class IssueReminderGql { + @Field({ description: 'When it tells me (ISO, UTC)' }) at!: string; + @Field(() => String, { nullable: true }) note!: string | null; +} + +@ObjectType('ReminderOnIssue', { + description: 'One of my waiting reminders, with its ticket', +}) +export class ReminderOnIssueGql { + @Field(() => IssueReminderGql) reminder!: IssueReminderGql; + @Field(() => IssueGql) issue!: 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 235e038..a49432f 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 @@ -1552,6 +1552,21 @@ export class PushEndpointDto { endpoint!: string; } +export class SetReminderDto { + @ApiProperty({ + format: 'date-time', + description: 'When to tell me; in the future, at most a year ahead', + }) + @IsISO8601() + at!: string; + + @ApiPropertyOptional({ maxLength: 280, description: 'What to remember' }) + @IsOptional() + @IsString() + @MaxLength(280) + note?: string; +} + export class ResolveThreadDto { @ApiPropertyOptional({ description: 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 cf6a0eb..31a132c 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 @@ -1417,3 +1417,13 @@ export class AlertOutcomeDto { @ApiProperty({ type: String, nullable: true, example: 'WEB-42' }) issueKey!: string | null; } + +export class IssueReminderDto { + @ApiProperty({ format: 'date-time' }) at!: string; + @ApiProperty({ type: String, nullable: true }) note!: string | null; +} + +export class ReminderOnIssueDto { + @ApiProperty({ type: IssueReminderDto }) reminder!: IssueReminderDto; + @ApiProperty({ type: () => IssueDto }) issue!: IssueDto; +} 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 5be85fd..93d8255 100644 --- a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts @@ -34,6 +34,7 @@ import { IssuesService, PinsService, TreeFollowsService, + IssueRemindersService, PresenceService, ProjectsService, } from '@task-stream/api/tracker/application'; @@ -47,6 +48,7 @@ import { AddCommentDto, CreateIssueDto, DecisionsQueryDto, + SetReminderDto, IssueSearchQueryDto, PresenceBodyDto, LinkIssueDto, @@ -66,6 +68,8 @@ import { CommentDto, CommentPageDto, DecisionDto, + IssueReminderDto, + ReminderOnIssueDto, EpicDto, IssueDto, IssuePageDto, @@ -101,6 +105,7 @@ export class IssuesController { private readonly projects: ProjectsService, private readonly pins: PinsService, private readonly treeFollows: TreeFollowsService, + private readonly reminders: IssueRemindersService, ) {} @Get('me/home') @@ -205,6 +210,55 @@ export class IssuesController { await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), false); } + @Get('me/reminders') + @ApiOperation({ summary: 'My waiting reminders on tickets, soonest first' }) + @ApiOkResponse({ type: [ReminderOnIssueDto] }) + @ApiReadErrors() + async myReminders(@CurrentUser() me: User) { + const mine = await this.reminders.mine(me.id); + const issues = await this.refs.issueDtos( + mine.map((m) => m.issue), + viewerOf(me), + ); + return mine.map((m, i) => ({ + reminder: { at: m.reminder.at, note: m.reminder.note }, + issue: issues[i], + })); + } + + @Put('issues/:issue/reminder') + @ISSUE + @ApiOperation({ + summary: 'Remind me of a ticket at a date and time', + description: + 'A notification (bell, push, e-mail) when the time comes. One per ticket: it replaces the waiting one. Private to me.', + }) + @ApiOkResponse({ type: IssueReminderDto }) + @ApiCommandErrors() + async setReminder( + @CurrentUser() me: User, + @Param('issue') issue: string, + @Body() body: SetReminderDto, + ) { + const reminder = await this.reminders.set( + me.id, + await this.refs.issueId(me.id, issue), + body.at, + body.note ?? null, + ); + return { at: reminder.at, note: reminder.note }; + } + + @Delete('issues/:issue/reminder') + @HttpCode(204) + @ISSUE + @ApiOperation({ summary: 'Drop my reminder on a ticket' }) + @ApiNoContentResponse() + @ApiCommandErrors() + async clearReminder(@CurrentUser() me: User, @Param('issue') issue: string) { + await this.reminders.clear(me.id, await this.refs.issueId(me.id, issue)); + } + @Put('issues/:issue/follow-tree') @HttpCode(204) @ISSUE diff --git a/libs/api/tracker/presenters/src/lib/schedulers/issue-reminder.scheduler.ts b/libs/api/tracker/presenters/src/lib/schedulers/issue-reminder.scheduler.ts new file mode 100644 index 0000000..ae4532c --- /dev/null +++ b/libs/api/tracker/presenters/src/lib/schedulers/issue-reminder.scheduler.ts @@ -0,0 +1,55 @@ +import { + Injectable, + Logger, + OnApplicationBootstrap, + OnApplicationShutdown, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { IssueRemindersService } from '@task-stream/api/tracker/application'; + +const MINUTE = 60_000; + +/** + * Tells people's own ticket reminders («remind me on Thursday») every + * minute. Each is told once (its notification id), so several instances + * running this at once only repeat lookups. REMINDERS_ENABLED=false turns it + * off, with the due-date ones. + */ +@Injectable() +export class IssueReminderScheduler + implements OnApplicationBootstrap, OnApplicationShutdown +{ + private readonly logger = new Logger(IssueReminderScheduler.name); + private timer: ReturnType | null = null; + private running = false; + + constructor( + private readonly reminders: IssueRemindersService, + private readonly config: ConfigService, + ) {} + + onApplicationBootstrap(): void { + if (String(this.config.get('REMINDERS_ENABLED') ?? 'true') === 'false') + return; + this.timer = setInterval(() => void this.run(), MINUTE); + this.timer.unref?.(); + } + + onApplicationShutdown(): void { + if (this.timer) clearInterval(this.timer); + } + + private async run(): Promise { + // A slow round must not pile up with the next. + if (this.running) return; + this.running = true; + try { + const sent = await this.reminders.sendDue(); + if (sent) this.logger.log(`Ticket reminders sent (${sent})`); + } catch (error) { + this.logger.warn(`Ticket reminders failed: ${(error as Error).message}`); + } finally { + this.running = false; + } + } +} diff --git a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts index 7c4b64b..56ab05e 100644 --- a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts +++ b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts @@ -17,6 +17,7 @@ import { CalendarController } from './http/calendar.controller'; import { CalendarResolver } from './graphql/calendar.resolver'; import { ReminderScheduler } from './schedulers/reminder.scheduler'; import { TrashScheduler } from './schedulers/trash.scheduler'; +import { IssueReminderScheduler } from './schedulers/issue-reminder.scheduler'; import { ProjectsController } from './http/projects.controller'; import { Refs } from './http/refs'; import { ReleasesController } from './http/releases.controller'; @@ -80,6 +81,7 @@ import { AutomationResolver } from './graphql/automation.resolver'; // Timers: entry points like the HTTP and GraphQL ones. ReminderScheduler, TrashScheduler, + IssueReminderScheduler, ProjectResolver, IssueResolver, CommentResolver, diff --git a/libs/web/tracker/application/src/lib/events/issue.events.ts b/libs/web/tracker/application/src/lib/events/issue.events.ts index df7375e..5188cb4 100644 --- a/libs/web/tracker/application/src/lib/events/issue.events.ts +++ b/libs/web/tracker/application/src/lib/events/issue.events.ts @@ -50,6 +50,12 @@ export const issuePageEvents = eventGroup({ watchRequested: type<{ issueId: string; watching: boolean }>(), pinRequested: type<{ issueId: string; pinned: boolean }>(), followTreeRequested: type<{ issueId: string; on: boolean }>(), + reminderRequested: type<{ + issueId: string; + /** null: drop it. */ + at: string | null; + note: string | null; + }>(), trashRequested: type<{ issueId: string; trashed: boolean; diff --git a/libs/web/tracker/application/src/lib/facades/issue.facade.ts b/libs/web/tracker/application/src/lib/facades/issue.facade.ts index 826c006..a389145 100644 --- a/libs/web/tracker/application/src/lib/facades/issue.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/issue.facade.ts @@ -120,6 +120,17 @@ export class IssueFacade { ); } + /** Remind me of the ticket then (null: drop it); one per ticket. */ + setReminder(at: string | null, note: string | null = null): void { + this.withIssue((issueId) => + this.dispatch.reminderRequested({ + issueId, + at, + note: note?.trim() || null, + }), + ); + } + /** Follow the ticket with everything under it (an epic), or stop. */ setFollowingTree(on: boolean): void { this.withIssue((issueId) => diff --git a/libs/web/tracker/application/src/lib/stores/issue.store.ts b/libs/web/tracker/application/src/lib/stores/issue.store.ts index 0a4fedc..f3603ee 100644 --- a/libs/web/tracker/application/src/lib/stores/issue.store.ts +++ b/libs/web/tracker/application/src/lib/stores/issue.store.ts @@ -22,6 +22,7 @@ import { IssueSummary, SprintsGateway, TrackerRealtimeGateway, + whenLabel, } from '@task-stream/web/tracker/domain'; import { concatMap, @@ -379,6 +380,26 @@ export const IssueStore = signalStore( ), ), ), + events + .on(issuePageEvents.reminderRequested) + .pipe( + mergeMap(({ payload }) => + runCommand( + payload.at + ? issues.setReminder( + payload.issueId, + payload.at, + payload.note, + ) + : issues.clearReminder(payload.issueId), + payload.at + ? `Te lo recordaré ${whenLabel(payload.at)}` + : 'Recordatorio quitado', + done, + failed, + ), + ), + ), events .on(issuePageEvents.followTreeRequested) .pipe( diff --git a/libs/web/tracker/domain/src/index.ts b/libs/web/tracker/domain/src/index.ts index a7e107d..744a203 100644 --- a/libs/web/tracker/domain/src/index.ts +++ b/libs/web/tracker/domain/src/index.ts @@ -1,6 +1,7 @@ export * from './lib/models/enums'; export * from './lib/models/models'; export * from './lib/models/bulk'; +export * from './lib/models/reminders'; export * from './lib/models/inputs'; export * from './lib/models/workflow'; export * from './lib/ports/gateways'; diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index c853dcc..0ee1d5a 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -196,6 +196,8 @@ export interface Issue extends IssueSummary { pinned: boolean; /** Whether I follow it with everything under it (an epic). */ followingTree: boolean; + /** My waiting reminder on it («remind me on Thursday»). */ + myReminder: IssueReminder | null; /** Its comments marked as decisions, oldest first. */ decisions: Comment[]; checklist: ChecklistItem[]; @@ -206,6 +208,13 @@ export interface Issue extends IssueSummary { } /** Someone else on a ticket's page right now. */ +/** My own reminder on a ticket. */ +export interface IssueReminder { + /** ISO date-time. */ + at: string; + note: string | null; +} + /** Someone writing on a ticket right now. */ export interface Typing { user: UserRef; diff --git a/libs/web/tracker/domain/src/lib/models/reminders.spec.ts b/libs/web/tracker/domain/src/lib/models/reminders.spec.ts new file mode 100644 index 0000000..c043c14 --- /dev/null +++ b/libs/web/tracker/domain/src/lib/models/reminders.spec.ts @@ -0,0 +1,40 @@ +import { reminderChoices } from './reminders'; + +const local = (y: number, m: number, d: number, h: number, min = 0) => + new Date(y, m - 1, d, h, min); + +describe('reminder choices', () => { + it('offers this evening only while there is time', () => { + // Tuesday 20 October 2026. + const morning = reminderChoices(local(2026, 10, 20, 10)).map( + (c) => c.label, + ); + expect(morning).toEqual([ + 'Dentro de 1 hora', + 'Esta tarde, a las 18:00', + 'Mañana a las 9:00', + 'El lunes a las 9:00', + 'Dentro de una semana', + ]); + const late = reminderChoices(local(2026, 10, 20, 19)).map((c) => c.label); + expect(late).not.toContain('Esta tarde, a las 18:00'); + }); + + it('lands on the right days and hours, local time', () => { + const [, evening, tomorrow, monday, week] = reminderChoices( + local(2026, 10, 20, 10, 30), + ).map((c) => new Date(c.at)); + expect([evening.getDate(), evening.getHours()]).toEqual([20, 18]); + expect([tomorrow.getDate(), tomorrow.getHours()]).toEqual([21, 9]); + expect([monday.getDay(), monday.getDate(), monday.getHours()]).toEqual([ + 1, 26, 9, + ]); + expect([week.getDate(), week.getHours()]).toEqual([27, 9]); + }); + + it('does not offer Monday twice on a Sunday (it is tomorrow)', () => { + const sunday = reminderChoices(local(2026, 10, 25, 10)).map((c) => c.label); + expect(sunday.filter((l) => l.includes('lunes'))).toEqual([]); + expect(sunday).toContain('Mañana a las 9:00'); + }); +}); diff --git a/libs/web/tracker/domain/src/lib/models/reminders.ts b/libs/web/tracker/domain/src/lib/models/reminders.ts new file mode 100644 index 0000000..9f24cc8 --- /dev/null +++ b/libs/web/tracker/domain/src/lib/models/reminders.ts @@ -0,0 +1,48 @@ +/** A ready-made moment for «remind me…». */ +export interface ReminderChoice { + label: string; + /** ISO date-time. */ + at: string; +} + +/** «el jue 23 a las 9:00», in the browser's time zone. */ +export function whenLabel(at: string): string { + const date = new Date(at); + const day = date.toLocaleDateString('es', { + weekday: 'short', + day: 'numeric', + month: 'short', + }); + const time = date.toLocaleTimeString('es', { + hour: '2-digit', + minute: '2-digit', + }); + return `el ${day} a las ${time}`; +} + +/** `now` moved `days` days on, at `hour`:00 (local time). */ +function at(now: Date, days: number, hour: number): Date { + const date = new Date(now); + date.setDate(date.getDate() + days); + date.setHours(hour, 0, 0, 0); + return date; +} + +/** + * The usual moments, from `now` in local time: in an hour, this evening + * (while there is time), tomorrow and next Monday at 9, and in a week. + */ +export function reminderChoices(now: Date = new Date()): ReminderChoice[] { + const choices: { label: string; at: Date }[] = [ + { label: 'Dentro de 1 hora', at: new Date(now.getTime() + 3_600_000) }, + ]; + if (now.getHours() < 17) + choices.push({ label: 'Esta tarde, a las 18:00', at: at(now, 0, 18) }); + choices.push({ label: 'Mañana a las 9:00', at: at(now, 1, 9) }); + // Monday is 1; from Sunday it is tomorrow, from Monday next week. + const toMonday = (8 - now.getDay()) % 7 || 7; + if (toMonday > 1) + choices.push({ label: 'El lunes a las 9:00', at: at(now, toMonday, 9) }); + choices.push({ label: 'Dentro de una semana', at: at(now, 7, 9) }); + return choices.map((c) => ({ label: c.label, at: c.at.toISOString() })); +} diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index 6f6742d..1dd34ea 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -150,6 +150,13 @@ export abstract class IssuesGateway { abstract pin(issueId: string, pinned: boolean): Observable; /** Follows the ticket with everything under it (or stops). */ abstract followTree(issueId: string, on: boolean): Observable; + /** Reminds me of the ticket then (replaces the waiting one). */ + abstract setReminder( + issueId: string, + at: string, + note: string | null, + ): Observable; + abstract clearReminder(issueId: string): Observable; /** * Decisions (comments marked as such) of all my projects by their text, * most recent first: here and not in CommentsGateway, for the palette. 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 6276c63..27f49ae 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 @@ -52,6 +52,8 @@ import { WatchIssueDocument, MyPinsDocument, FollowTreeDocument, + SetReminderDocument, + ClearReminderDocument, UnfollowTreeDocument, SearchDecisionsDocument, PinIssueDocument, @@ -220,6 +222,22 @@ export class GraphqlIssuesGateway extends IssuesGateway { ), ); } + setReminder( + issueId: string, + at: string, + note: string | null, + ): Observable { + return this.gql + .mutate(SetReminderDocument, { issueId, at, note }) + .pipe(map(() => undefined)); + } + + clearReminder(issueId: string): Observable { + return this.gql + .mutate(ClearReminderDocument, { issueId }) + .pipe(map(() => undefined)); + } + followTree(issueId: string, on: boolean): Observable { const done$: Observable = on ? this.gql.mutate(FollowTreeDocument, { issueId }) diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql index 23e40f6..24a2506 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql @@ -122,6 +122,10 @@ fragment IssueFields on Issue { ...IssueSummaryFields pinned followingTree + myReminder { + at + note + } decisions { ...CommentFields } diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index 9fa76cf..4338b92 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -714,6 +714,7 @@ export type IssueFieldsFragment = { startDate: string | null; dueDate: string | null; trashedAt: string | null; + myReminder: { at: string; note: string | null } | null; decisions: Array<{ id: string; issueId: string; @@ -960,6 +961,7 @@ export type IssueQuery = { startDate: string | null; dueDate: string | null; trashedAt: string | null; + myReminder: { at: string; note: string | null } | null; decisions: Array<{ id: string; issueId: string; @@ -1153,6 +1155,20 @@ export type MyPinsQuery = { }>; }; +export type SetReminderMutationVariables = Exact<{ + issueId: string; + at: string; + note?: string | null | undefined; +}>; + +export type SetReminderMutation = { setReminder: { at: string } }; + +export type ClearReminderMutationVariables = Exact<{ + issueId: string; +}>; + +export type ClearReminderMutation = { clearReminder: boolean }; + export type FollowTreeMutationVariables = Exact<{ issueId: string; }>; @@ -3836,6 +3852,17 @@ export const IssueFieldsFragmentDoc = { }, { kind: 'Field', name: { kind: 'Name', value: 'pinned' } }, { kind: 'Field', name: { kind: 'Name', value: 'followingTree' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'myReminder' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'at' } }, + { kind: 'Field', name: { kind: 'Name', value: 'note' } }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'decisions' }, @@ -6437,6 +6464,17 @@ export const IssueDocument = { }, { kind: 'Field', name: { kind: 'Name', value: 'pinned' } }, { kind: 'Field', name: { kind: 'Name', value: 'followingTree' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'myReminder' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'at' } }, + { kind: 'Field', name: { kind: 'Name', value: 'note' } }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'decisions' }, @@ -7469,6 +7507,131 @@ export const MyPinsDocument = { }, ], } as unknown as DocumentNode; +export const SetReminderDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'SetReminder' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'at' } }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'String' }, + }, + }, + }, + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'note' } }, + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'setReminder' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'issueId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'at' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'at' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'note' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'note' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'at' } }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const ClearReminderDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'ClearReminder' }, + 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: 'clearReminder' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'issueId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + }, + ], + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + ClearReminderMutation, + ClearReminderMutationVariables +>; export const FollowTreeDocument = { 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 f6ba3d7..63d9b0a 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql @@ -101,6 +101,16 @@ query MyPins { } } +mutation SetReminder($issueId: ID!, $at: String!, $note: String) { + setReminder(issueId: $issueId, at: $at, note: $note) { + at + } +} + +mutation ClearReminder($issueId: ID!) { + clearReminder(issueId: $issueId) +} + mutation FollowTree($issueId: ID!) { followTree(issueId: $issueId) } diff --git a/libs/web/tracker/presenters/src/lib/pages/issue.page.scss b/libs/web/tracker/presenters/src/lib/pages/issue.page.scss index 42805f8..f0dbfa2 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.scss +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.scss @@ -768,3 +768,72 @@ a.ts-key { border-radius: 50%; background: var(--ts-c-green, #2e9e6a); } + +/* My own reminder on the ticket. */ +.ts-reminder { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem 0.625rem; + margin: 0; + padding: 0.5rem 0.875rem; + border-radius: 0.625rem; + background: color-mix(in srgb, var(--ts-c-amber) 14%, transparent); + font-size: 0.875rem; +} + +/* Small text buttons, not @pi/ui's sized ones. */ +.ts-reminder__action { + height: auto !important; + min-width: 0 !important; + padding: 0 !important; + border: 0; + background: none !important; + color: var(--ts-brand); + font: inherit; + font-weight: 600; + cursor: pointer; + + &:hover, + &:focus-visible { + text-decoration: underline; + } +} + +.ts-reminder__choices { + display: grid; + gap: 0.375rem; +} + +.ts-reminder__choice { + display: flex; + justify-content: space-between; + gap: 1rem; + height: auto !important; + min-width: 0 !important; + padding: 0.625rem 0.875rem !important; + border: 1px solid var(--ts-line); + border-radius: 0.5rem; + background: var(--ts-surface); + color: var(--ts-ink); + font: inherit; + text-align: left; + cursor: pointer; + + &:hover, + &:focus-visible { + border-color: var(--ts-brand); + } +} + +.ts-reminder__field { + display: grid; + gap: 0.375rem; + color: var(--ts-muted); + font-size: 0.8125rem; + + .ts-input { + font: inherit; + color: var(--ts-ink); + } +} diff --git a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts index d794d3c..86f89ae 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts @@ -53,7 +53,10 @@ import { isTyping, POINT_SCALE, ReleaseStatus, + ReminderChoice, + reminderChoices, StatusCategory, + whenLabel, } from '@task-stream/web/tracker/domain'; import { clock, @@ -206,6 +209,17 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; (execute)="facade.setWatching(!facade.watching())" /> } + @if (project.myRole()) { + + } @if ( project.myRole() && (facade.children().length || issue.followingTree) @@ -265,6 +279,30 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; + @if (issue.myReminder; as reminder) { +

+ + Te lo recordaré {{ when(reminder.at) }} + @if (reminder.note) { + · «{{ reminder.note }}» + } + + +

+ } + @if (project.canContribute() && issue.allowedTransitions.length) {
Mover a @@ -1163,6 +1201,58 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; } + +
+

+ Te llegará un aviso (en la campana y, si los tienes activados, en este + dispositivo o por correo). Solo lo ves tú. +

+
+ @for (choice of reminderChoices(); track choice.label) { + + } +
+ + + + +
+
>('reasonForm'); + private readonly reminderForm = + viewChild.required>('reminderForm'); + /** The reminder being written: `datetime-local` value and note. */ + protected readonly reminderAt = signal(''); + protected readonly reminderNote = signal(''); + protected readonly reminderChoices = signal([]); + protected readonly when = whenLabel; private readonly editForm = viewChild.required>('editForm'); private readonly commentEditForm = @@ -1638,6 +1735,28 @@ export class IssuePage { this.parentKey.set(null); } + protected openReminder(): void { + const current = this.facade.issue()?.myReminder; + this.reminderChoices.set(reminderChoices()); + this.reminderAt.set(current ? toLocalInput(new Date(current.at)) : ''); + this.reminderNote.set(current?.note ?? ''); + this.drawer.open('Recordármelo', this.reminderForm()); + } + + /** `at`: ISO, or the `datetime-local` value (local time). */ + protected saveReminder(at: string): void { + if (!at) return; + const when = new Date(at); + if (Number.isNaN(when.getTime())) return; + this.facade.setReminder(when.toISOString(), this.reminderNote()); + this.drawer.closeAll(); + } + + /** Now, as the `min` of a `datetime-local` input. */ + protected nowLocal(): string { + return toLocalInput(new Date()); + } + protected move(to: string): void { const open = this.openThreads(); if ( @@ -1934,3 +2053,9 @@ export class IssuePage { this.drawer.closeAll(); } } + +/** A date as a `datetime-local` value (local time, to the minute). */ +function toLocalInput(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +}