feat: «Recordármelo», a reminder of a ticket only for me

On a ticket, «Recordármelo» offers in an hour, this evening, tomorrow or
next Monday at 9, in a week, or any date and time, with an optional note.
The ticket shows the waiting one («Te lo recordaré el mié 21 a las 9:00»)
to change or drop. Every minute a scheduler tells the ones whose time
came as a REMINDER notification (bell, push, e-mail), once, and forgets
those whose ticket went to the trash or out of reach. One per person and
ticket, private, no history (Mongo `issue_reminders`).

API: setReminder / clearReminder, Issue.myReminder, myReminders;
PUT/DELETE /api/issues/{issue}/reminder, GET /api/me/reminders. MCP:
remind_me.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 20:08:50 +02:00
parent d79ef418d3
commit b88296c22b
37 changed files with 2020 additions and 50 deletions

View File

@@ -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": { "/api/issues/{issue}/follow-tree": {
"put": { "put": {
"description": "Told of its changes and of every ticket hanging from it, at any depth, also the ones added later. Private to me.", "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" "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": { "IssuePageDto": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -705,6 +705,9 @@ type Issue {
"""Linked tickets I can see""" """Linked tickets I can see"""
linkedIssues: [Issue!]! linkedIssues: [Issue!]!
links: [IssueLink!]! links: [IssueLink!]!
"""My waiting reminder on it (null without one or a token)"""
myReminder: IssueReminder
number: Int! number: Int!
"""The epic or ticket this one breaks down""" """The epic or ticket this one breaks down"""
@@ -845,6 +848,13 @@ enum IssuePriority {
MEDIUM 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.""" """RECENT: last updated first. RANK: backlog order."""
enum IssueSort { enum IssueSort {
RANK RANK
@@ -971,6 +981,9 @@ type Mutation {
changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult! changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult!
changeProjectMemberRole(input: ProjectMemberInput!): 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. 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)""" """Put a ticket in a sprint (null: back to the backlog)"""
setIssueSprint(issueId: ID!, sprintId: ID): CommandResult! 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""" """Turn the link on or change its blocks; renew for a new secret"""
shareDashboard(input: ShareDashboardInput!): DashboardShare! shareDashboard(input: ShareDashboardInput!): DashboardShare!
@@ -1645,6 +1668,9 @@ type Query {
"""Projects where I am a member""" """Projects where I am a member"""
myProjects: [Project!]! myProjects: [Project!]!
"""My waiting reminders on tickets, soonest first"""
myReminders: [ReminderOnIssue!]!
"""My saved views of my work, across my projects""" """My saved views of my work, across my projects"""
myViews: [MyView!]! myViews: [MyView!]!
notificationSettings: NotificationSettings! notificationSettings: NotificationSettings!
@@ -1779,6 +1805,12 @@ enum ReleaseStatus {
RELEASED RELEASED
} }
"""One of my waiting reminders, with its ticket"""
type ReminderOnIssue {
issue: Issue!
reminder: IssueReminder!
}
"""Tickets to look at now (worst first)""" """Tickets to look at now (worst first)"""
type Risks { type Risks {
blocked: [InsightIssue!]! blocked: [InsightIssue!]!

View File

@@ -502,6 +502,44 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/issues/{issue}/follow-tree": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2913,6 +2951,24 @@ export interface components {
*/ */
at: string; 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: { IssuePageDto: {
items: components["schemas"]["IssueDto"][]; items: components["schemas"]["IssueDto"][];
/** @description Total matches (for pagination) */ /** @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: { IssuesController_followTree: {
parameters: { parameters: {
query?: never; query?: never;

View File

@@ -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({ @Tool({
name: 'resolve_thread', name: 'resolve_thread',
description: description:

View File

@@ -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, sigue un ticket (`WATCHED`) sabe cuándo cambia de estado, se asigna, se comenta, cambia su prioridad o su fecha límite,
o entra o sale de un sprint; las ediciones menores (título, etiquetas…) no avisan. «Los que sigo»: `watching` en o entra o sale de un sprint; las ediciones menores (título, etiquetas…) no avisan. «Los que sigo»: `watching` en
`myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`, `myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`,
`?watching=true`; sin sesión, ninguno). **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 `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 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 uno entra (`IssueParentChangedEvent`). Personal y sin historial (colección `tree_follows`); los invitados de un proyecto
@@ -545,9 +550,9 @@ que funcionan también sin token.
Todas devuelven `CommandResult { id, key, version }`. Todas devuelven `CommandResult { id, key, version }`.
| Ámbito | Mutations | | Ámbito | Mutations |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` | | Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `pinIssue`, `unpinIssue`, `followTree`, `unfollowTree` (devuelven `true`), `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` | | 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)` | | 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` | | Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` | | Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
@@ -753,10 +758,10 @@ Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`). Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
| Recurso | Rutas | | Recurso | Rutas |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` | | Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) | | Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
| Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT/DELETE …/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` | | 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` | | 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` | | Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` | | Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |

View File

@@ -301,14 +301,14 @@ visualización de la plataforma:
## 4.6 Rutas ## 4.6 Rutas
| Ruta | Página | | Ruta | Página |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/login` | Inicio de sesión (solo sin sesión) | | `/login` | Inicio de sesión (solo sin sesión) |
| `/auth/callback` | Retorno de Pireon (PKCE) | | `/auth/callback` | Retorno de Pireon (PKCE) |
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), 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» | | `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» |
| `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos | | `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos |
| `/explore` | Proyectos públicos (sin sesión) | | `/explore` | Proyectos públicos (sin sesión) |
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar | | `/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=<id>` 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/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=<id>` 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/releases` | Releases |
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) | | `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea | | `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |

View File

@@ -68,6 +68,7 @@ flowchart LR
| | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción | | | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción |
| | `mark_decision` | Marcar un comentario como decisión del ticket, o quitarla | | | `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 | | | `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 | | | `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 | | | `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 | | Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |

View File

@@ -43,6 +43,9 @@ export * from './lib/alerts.service';
export * from './lib/follows/tree-follows'; export * from './lib/follows/tree-follows';
export * from './lib/ports/tree-follows.repository'; export * from './lib/ports/tree-follows.repository';
export * from './lib/tree-follows.service'; 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/tracker-application.module';
export * from './lib/ports/presence.store'; export * from './lib/ports/presence.store';
export * from './lib/presence.service'; export * from './lib/presence.service';

View File

@@ -0,0 +1,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<IssueReminder> {
return this.commands.execute(
new SetIssueReminderCommand(userId, issueId, at, note),
);
}
clear(userId: string, issueId: string): Promise<void> {
return this.commands.execute(
new ClearIssueReminderCommand(userId, issueId),
);
}
of(userId: string, issueId: string): Promise<IssueReminder | null> {
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<number> {
return this.commands.execute(new SendIssueRemindersCommand());
}
}

View File

@@ -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<void>;
abstract waitingFor(
userId: string,
issueId: string,
): Promise<IssueReminder | null>;
/** One person's waiting reminders, soonest first. */
abstract waitingOf(userId: string): Promise<IssueReminder[]>;
abstract remove(userId: string, issueId: string): Promise<void>;
/** Waiting ones whose time has come, oldest first. */
abstract due(now: string, limit: number): Promise<IssueReminder[]>;
abstract markSent(id: string, at: string): Promise<void>;
}

View File

@@ -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<string, IssueReadModel>();
@QueryHandler(GetIssuesByIdsQuery)
class FakeIssuesByIds implements IQueryHandler<GetIssuesByIdsQuery> {
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,
);
});
});

View File

@@ -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<IssueReminder> {
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<void> {
constructor(
public readonly userId: string,
public readonly issueId: string,
) {
super();
}
}
/** Scheduler only: tells the reminders whose time has come. */
export class SendIssueRemindersCommand extends Command<number> {
constructor(public readonly now: string = new Date().toISOString()) {
super();
}
}
export class GetIssueReminderQuery extends Query<IssueReminder | null> {
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<SetIssueReminderCommand>
{
constructor(
private readonly reminders: IssueRemindersRepository,
private readonly queryBus: QueryBus,
) {}
async execute(command: SetIssueReminderCommand): Promise<IssueReminder> {
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<ClearIssueReminderCommand>
{
constructor(private readonly reminders: IssueRemindersRepository) {}
execute(command: ClearIssueReminderCommand): Promise<void> {
return this.reminders.remove(command.userId, command.issueId);
}
}
@CommandHandler(SendIssueRemindersCommand)
export class SendIssueRemindersCommandHandler
implements ICommandHandler<SendIssueRemindersCommand>
{
constructor(
private readonly reminders: IssueRemindersRepository,
private readonly queryBus: QueryBus,
private readonly notifier: Notifier,
) {}
async execute(command: SendIssueRemindersCommand): Promise<number> {
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<GetIssueReminderQuery, IssueReminder | null>
{
constructor(private readonly reminders: IssueRemindersRepository) {}
execute(query: GetIssueReminderQuery): Promise<IssueReminder | null> {
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,
];

View File

@@ -38,6 +38,8 @@ import { MY_VIEWS } from './saved-filters/my-views';
import { PINS } from './saved-filters/pins'; import { PINS } from './saved-filters/pins';
import { ALERT_HANDLERS } from './alerts/alerts'; import { ALERT_HANDLERS } from './alerts/alerts';
import { TREE_FOLLOWS } from './follows/tree-follows'; 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 { TreeFollowsService } from './tree-follows.service';
import { AlertsService } from './alerts.service'; import { AlertsService } from './alerts.service';
import { PinsService } from './pins.service'; import { PinsService } from './pins.service';
@@ -87,6 +89,7 @@ const SERVICES = [
PinsService, PinsService,
AlertsService, AlertsService,
TreeFollowsService, TreeFollowsService,
IssueRemindersService,
SprintsService, SprintsService,
GitService, GitService,
WebhooksService, WebhooksService,
@@ -120,6 +123,7 @@ const SERVICES = [
...PINS, ...PINS,
...ALERT_HANDLERS, ...ALERT_HANDLERS,
...TREE_FOLLOWS, ...TREE_FOLLOWS,
...ISSUE_REMINDERS,
...GIT_HANDLERS, ...GIT_HANDLERS,
...WEBHOOK_HANDLERS, ...WEBHOOK_HANDLERS,
...TRANSFER_HANDLERS, ...TRANSFER_HANDLERS,

View File

@@ -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<Stored>,
) {
super();
}
async save(reminder: IssueReminder): Promise<void> {
await this.remove(reminder.userId, reminder.issueId);
await this.reminders.create(reminder);
}
async waitingFor(
userId: string,
issueId: string,
): Promise<IssueReminder | null> {
const doc = await this.reminders
.findOne({ userId, issueId, sentAt: null })
.lean<Stored>();
return doc ? strip(doc) : null;
}
async waitingOf(userId: string): Promise<IssueReminder[]> {
const docs = await this.reminders
.find({ userId, sentAt: null })
.sort({ at: 1 })
.lean<Stored[]>();
return docs.map(strip);
}
async remove(userId: string, issueId: string): Promise<void> {
await this.reminders.deleteMany({ userId, issueId, sentAt: null });
}
async due(now: string, limit: number): Promise<IssueReminder[]> {
const docs = await this.reminders
.find({ sentAt: null, at: { $lte: now } })
.sort({ at: 1 })
.limit(limit)
.lean<Stored[]>();
return docs.map(strip);
}
async markSent(id: string, at: string): Promise<void> {
await this.reminders.updateOne({ id }, { $set: { sentAt: at } });
}
}

View File

@@ -110,6 +110,7 @@ export const ReadModels = {
AlertIntegration: 'AlertIntegration', AlertIntegration: 'AlertIntegration',
AlertTrace: 'AlertTrace', AlertTrace: 'AlertTrace',
TreeFollow: 'TreeFollow', TreeFollow: 'TreeFollow',
IssueReminder: 'IssueReminder',
Sprint: 'SprintReadModel', Sprint: 'SprintReadModel',
GitIntegration: 'GitIntegration', GitIntegration: 'GitIntegration',
OutgoingWebhook: 'OutgoingWebhook', OutgoingWebhook: 'OutgoingWebhook',
@@ -370,6 +371,20 @@ export const TreeFollowSchema =
SchemaFactory.createForClass(TreeFollowDocument); SchemaFactory.createForClass(TreeFollowDocument);
TreeFollowSchema.index({ userId: 1, issueId: 1 }, { unique: true }); 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' }) @Schema({ ...LOOSE, collection: 'git_integrations' })
class GitIntegrationDocument { class GitIntegrationDocument {
/** The project's id. */ /** The project's id. */
@@ -476,6 +491,7 @@ export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.AlertIntegration, schema: AlertIntegrationSchema }, { name: ReadModels.AlertIntegration, schema: AlertIntegrationSchema },
{ name: ReadModels.AlertTrace, schema: AlertTraceSchema }, { name: ReadModels.AlertTrace, schema: AlertTraceSchema },
{ name: ReadModels.TreeFollow, schema: TreeFollowSchema }, { name: ReadModels.TreeFollow, schema: TreeFollowSchema },
{ name: ReadModels.IssueReminder, schema: IssueReminderSchema },
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema }, { name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
{ name: ReadModels.DashboardShare, schema: DashboardShareSchema }, { name: ReadModels.DashboardShare, schema: DashboardShareSchema },
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema }, { name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },

View File

@@ -16,6 +16,7 @@ import {
PinsRepository, PinsRepository,
AlertIntegrationsRepository, AlertIntegrationsRepository,
TreeFollowsRepository, TreeFollowsRepository,
IssueRemindersRepository,
GitIntegrationsRepository, GitIntegrationsRepository,
OutgoingWebhooksRepository, OutgoingWebhooksRepository,
AutomationsRepository, AutomationsRepository,
@@ -84,6 +85,7 @@ import { MongoMyViewsRepository } from './mongo/my-views.repository';
import { MongoPinsRepository } from './mongo/pins.repository'; import { MongoPinsRepository } from './mongo/pins.repository';
import { MongoAlertIntegrationsRepository } from './mongo/alert-integrations.repository'; import { MongoAlertIntegrationsRepository } from './mongo/alert-integrations.repository';
import { MongoTreeFollowsRepository } from './mongo/tree-follows.repository'; import { MongoTreeFollowsRepository } from './mongo/tree-follows.repository';
import { MongoIssueRemindersRepository } from './mongo/issue-reminders.repository';
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository'; import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository'; import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
import { HttpWebhookSender } from './webhooks/http-webhook.sender'; import { HttpWebhookSender } from './webhooks/http-webhook.sender';
@@ -166,6 +168,10 @@ const PORTS = [
useClass: MongoAlertIntegrationsRepository, useClass: MongoAlertIntegrationsRepository,
}, },
{ provide: TreeFollowsRepository, useClass: MongoTreeFollowsRepository }, { provide: TreeFollowsRepository, useClass: MongoTreeFollowsRepository },
{
provide: IssueRemindersRepository,
useClass: MongoIssueRemindersRepository,
},
{ {
provide: GitIntegrationsRepository, provide: GitIntegrationsRepository,
useClass: MongoGitIntegrationsRepository, useClass: MongoGitIntegrationsRepository,

View File

@@ -19,6 +19,7 @@ import {
IssuesService, IssuesService,
PinsService, PinsService,
TreeFollowsService, TreeFollowsService,
IssueRemindersService,
PresenceService, PresenceService,
WorkflowsService, WorkflowsService,
} from '@task-stream/api/tracker/application'; } from '@task-stream/api/tracker/application';
@@ -52,6 +53,8 @@ import {
IssueGql, IssueGql,
IssuePageGql, IssuePageGql,
IssueParentGql, IssueParentGql,
IssueReminderGql,
ReminderOnIssueGql,
PresenceGql, PresenceGql,
} from './types'; } from './types';
@@ -65,6 +68,7 @@ export class IssueResolver {
private readonly presence: PresenceService, private readonly presence: PresenceService,
private readonly pins: PinsService, private readonly pins: PinsService,
private readonly treeFollows: TreeFollowsService, private readonly treeFollows: TreeFollowsService,
private readonly reminders: IssueRemindersService,
) {} ) {}
@Mutation(() => [PresenceGql], { @Mutation(() => [PresenceGql], {
@@ -282,6 +286,17 @@ export class IssueResolver {
return this.commentsService.decisions(viewerOf(me), issue.id); 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, { @ResolveField(() => Boolean, {
description: description:
'Whether I follow it with everything under it (false without a token)', 'Whether I follow it with everything under it (false without a token)',
@@ -427,6 +442,35 @@ export class IssueResolver {
return true; 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<boolean> {
await this.reminders.clear(me.id, issueId);
return true;
}
@Mutation(() => CommandResultGql, { description: 'Stop following a ticket' }) @Mutation(() => CommandResultGql, { description: 'Stop following a ticket' })
unwatchIssue( unwatchIssue(
@CurrentUser() me: User, @CurrentUser() me: User,

View File

@@ -1377,3 +1377,19 @@ export class AlertIntegrationGql {
typeId!: string | null; typeId!: string | null;
@Field(() => IssuePriority) priority!: IssuePriority; @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;
}

View File

@@ -1552,6 +1552,21 @@ export class PushEndpointDto {
endpoint!: string; 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 { export class ResolveThreadDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:

View File

@@ -1417,3 +1417,13 @@ export class AlertOutcomeDto {
@ApiProperty({ type: String, nullable: true, example: 'WEB-42' }) @ApiProperty({ type: String, nullable: true, example: 'WEB-42' })
issueKey!: string | null; 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;
}

View File

@@ -34,6 +34,7 @@ import {
IssuesService, IssuesService,
PinsService, PinsService,
TreeFollowsService, TreeFollowsService,
IssueRemindersService,
PresenceService, PresenceService,
ProjectsService, ProjectsService,
} from '@task-stream/api/tracker/application'; } from '@task-stream/api/tracker/application';
@@ -47,6 +48,7 @@ import {
AddCommentDto, AddCommentDto,
CreateIssueDto, CreateIssueDto,
DecisionsQueryDto, DecisionsQueryDto,
SetReminderDto,
IssueSearchQueryDto, IssueSearchQueryDto,
PresenceBodyDto, PresenceBodyDto,
LinkIssueDto, LinkIssueDto,
@@ -66,6 +68,8 @@ import {
CommentDto, CommentDto,
CommentPageDto, CommentPageDto,
DecisionDto, DecisionDto,
IssueReminderDto,
ReminderOnIssueDto,
EpicDto, EpicDto,
IssueDto, IssueDto,
IssuePageDto, IssuePageDto,
@@ -101,6 +105,7 @@ export class IssuesController {
private readonly projects: ProjectsService, private readonly projects: ProjectsService,
private readonly pins: PinsService, private readonly pins: PinsService,
private readonly treeFollows: TreeFollowsService, private readonly treeFollows: TreeFollowsService,
private readonly reminders: IssueRemindersService,
) {} ) {}
@Get('me/home') @Get('me/home')
@@ -205,6 +210,55 @@ export class IssuesController {
await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), false); 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') @Put('issues/:issue/follow-tree')
@HttpCode(204) @HttpCode(204)
@ISSUE @ISSUE

View File

@@ -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<typeof setInterval> | 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<void> {
// 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;
}
}
}

View File

@@ -17,6 +17,7 @@ import { CalendarController } from './http/calendar.controller';
import { CalendarResolver } from './graphql/calendar.resolver'; import { CalendarResolver } from './graphql/calendar.resolver';
import { ReminderScheduler } from './schedulers/reminder.scheduler'; import { ReminderScheduler } from './schedulers/reminder.scheduler';
import { TrashScheduler } from './schedulers/trash.scheduler'; import { TrashScheduler } from './schedulers/trash.scheduler';
import { IssueReminderScheduler } from './schedulers/issue-reminder.scheduler';
import { ProjectsController } from './http/projects.controller'; import { ProjectsController } from './http/projects.controller';
import { Refs } from './http/refs'; import { Refs } from './http/refs';
import { ReleasesController } from './http/releases.controller'; 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. // Timers: entry points like the HTTP and GraphQL ones.
ReminderScheduler, ReminderScheduler,
TrashScheduler, TrashScheduler,
IssueReminderScheduler,
ProjectResolver, ProjectResolver,
IssueResolver, IssueResolver,
CommentResolver, CommentResolver,

View File

@@ -50,6 +50,12 @@ export const issuePageEvents = eventGroup({
watchRequested: type<{ issueId: string; watching: boolean }>(), watchRequested: type<{ issueId: string; watching: boolean }>(),
pinRequested: type<{ issueId: string; pinned: boolean }>(), pinRequested: type<{ issueId: string; pinned: boolean }>(),
followTreeRequested: type<{ issueId: string; on: boolean }>(), followTreeRequested: type<{ issueId: string; on: boolean }>(),
reminderRequested: type<{
issueId: string;
/** null: drop it. */
at: string | null;
note: string | null;
}>(),
trashRequested: type<{ trashRequested: type<{
issueId: string; issueId: string;
trashed: boolean; trashed: boolean;

View File

@@ -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. */ /** Follow the ticket with everything under it (an epic), or stop. */
setFollowingTree(on: boolean): void { setFollowingTree(on: boolean): void {
this.withIssue((issueId) => this.withIssue((issueId) =>

View File

@@ -22,6 +22,7 @@ import {
IssueSummary, IssueSummary,
SprintsGateway, SprintsGateway,
TrackerRealtimeGateway, TrackerRealtimeGateway,
whenLabel,
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
import { import {
concatMap, 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 events
.on(issuePageEvents.followTreeRequested) .on(issuePageEvents.followTreeRequested)
.pipe( .pipe(

View File

@@ -1,6 +1,7 @@
export * from './lib/models/enums'; export * from './lib/models/enums';
export * from './lib/models/models'; export * from './lib/models/models';
export * from './lib/models/bulk'; export * from './lib/models/bulk';
export * from './lib/models/reminders';
export * from './lib/models/inputs'; export * from './lib/models/inputs';
export * from './lib/models/workflow'; export * from './lib/models/workflow';
export * from './lib/ports/gateways'; export * from './lib/ports/gateways';

View File

@@ -196,6 +196,8 @@ export interface Issue extends IssueSummary {
pinned: boolean; pinned: boolean;
/** Whether I follow it with everything under it (an epic). */ /** Whether I follow it with everything under it (an epic). */
followingTree: boolean; followingTree: boolean;
/** My waiting reminder on it («remind me on Thursday»). */
myReminder: IssueReminder | null;
/** Its comments marked as decisions, oldest first. */ /** Its comments marked as decisions, oldest first. */
decisions: Comment[]; decisions: Comment[];
checklist: ChecklistItem[]; checklist: ChecklistItem[];
@@ -206,6 +208,13 @@ export interface Issue extends IssueSummary {
} }
/** Someone else on a ticket's page right now. */ /** 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. */ /** Someone writing on a ticket right now. */
export interface Typing { export interface Typing {
user: UserRef; user: UserRef;

View File

@@ -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');
});
});

View File

@@ -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() }));
}

View File

@@ -150,6 +150,13 @@ export abstract class IssuesGateway {
abstract pin(issueId: string, pinned: boolean): Observable<void>; abstract pin(issueId: string, pinned: boolean): Observable<void>;
/** Follows the ticket with everything under it (or stops). */ /** Follows the ticket with everything under it (or stops). */
abstract followTree(issueId: string, on: boolean): Observable<void>; abstract followTree(issueId: string, on: boolean): Observable<void>;
/** Reminds me of the ticket then (replaces the waiting one). */
abstract setReminder(
issueId: string,
at: string,
note: string | null,
): Observable<void>;
abstract clearReminder(issueId: string): Observable<void>;
/** /**
* Decisions (comments marked as such) of all my projects by their text, * Decisions (comments marked as such) of all my projects by their text,
* most recent first: here and not in CommentsGateway, for the palette. * most recent first: here and not in CommentsGateway, for the palette.

View File

@@ -52,6 +52,8 @@ import {
WatchIssueDocument, WatchIssueDocument,
MyPinsDocument, MyPinsDocument,
FollowTreeDocument, FollowTreeDocument,
SetReminderDocument,
ClearReminderDocument,
UnfollowTreeDocument, UnfollowTreeDocument,
SearchDecisionsDocument, SearchDecisionsDocument,
PinIssueDocument, PinIssueDocument,
@@ -220,6 +222,22 @@ export class GraphqlIssuesGateway extends IssuesGateway {
), ),
); );
} }
setReminder(
issueId: string,
at: string,
note: string | null,
): Observable<void> {
return this.gql
.mutate(SetReminderDocument, { issueId, at, note })
.pipe(map(() => undefined));
}
clearReminder(issueId: string): Observable<void> {
return this.gql
.mutate(ClearReminderDocument, { issueId })
.pipe(map(() => undefined));
}
followTree(issueId: string, on: boolean): Observable<void> { followTree(issueId: string, on: boolean): Observable<void> {
const done$: Observable<unknown> = on const done$: Observable<unknown> = on
? this.gql.mutate(FollowTreeDocument, { issueId }) ? this.gql.mutate(FollowTreeDocument, { issueId })

View File

@@ -122,6 +122,10 @@ fragment IssueFields on Issue {
...IssueSummaryFields ...IssueSummaryFields
pinned pinned
followingTree followingTree
myReminder {
at
note
}
decisions { decisions {
...CommentFields ...CommentFields
} }

View File

@@ -714,6 +714,7 @@ export type IssueFieldsFragment = {
startDate: string | null; startDate: string | null;
dueDate: string | null; dueDate: string | null;
trashedAt: string | null; trashedAt: string | null;
myReminder: { at: string; note: string | null } | null;
decisions: Array<{ decisions: Array<{
id: string; id: string;
issueId: string; issueId: string;
@@ -960,6 +961,7 @@ export type IssueQuery = {
startDate: string | null; startDate: string | null;
dueDate: string | null; dueDate: string | null;
trashedAt: string | null; trashedAt: string | null;
myReminder: { at: string; note: string | null } | null;
decisions: Array<{ decisions: Array<{
id: string; id: string;
issueId: 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<{ export type FollowTreeMutationVariables = Exact<{
issueId: string; issueId: string;
}>; }>;
@@ -3836,6 +3852,17 @@ export const IssueFieldsFragmentDoc = {
}, },
{ kind: 'Field', name: { kind: 'Name', value: 'pinned' } }, { kind: 'Field', name: { kind: 'Name', value: 'pinned' } },
{ kind: 'Field', name: { kind: 'Name', value: 'followingTree' } }, { 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', kind: 'Field',
name: { kind: 'Name', value: 'decisions' }, 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: 'pinned' } },
{ kind: 'Field', name: { kind: 'Name', value: 'followingTree' } }, { 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', kind: 'Field',
name: { kind: 'Name', value: 'decisions' }, name: { kind: 'Name', value: 'decisions' },
@@ -7469,6 +7507,131 @@ export const MyPinsDocument = {
}, },
], ],
} as unknown as DocumentNode<MyPinsQuery, MyPinsQueryVariables>; } as unknown as DocumentNode<MyPinsQuery, MyPinsQueryVariables>;
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<SetReminderMutation, SetReminderMutationVariables>;
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 = { export const FollowTreeDocument = {
kind: 'Document', kind: 'Document',
definitions: [ definitions: [

View File

@@ -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!) { mutation FollowTree($issueId: ID!) {
followTree(issueId: $issueId) followTree(issueId: $issueId)
} }

View File

@@ -768,3 +768,72 @@ a.ts-key {
border-radius: 50%; border-radius: 50%;
background: var(--ts-c-green, #2e9e6a); 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);
}
}

View File

@@ -53,7 +53,10 @@ import {
isTyping, isTyping,
POINT_SCALE, POINT_SCALE,
ReleaseStatus, ReleaseStatus,
ReminderChoice,
reminderChoices,
StatusCategory, StatusCategory,
whenLabel,
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
import { import {
clock, clock,
@@ -206,6 +209,17 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
(execute)="facade.setWatching(!facade.watching())" (execute)="facade.setWatching(!facade.watching())"
/> />
} }
@if (project.myRole()) {
<pi-icon-button
icon="bell"
color="grey"
appearance="outline"
[text]="issue.myReminder ? 'Recordatorio' : 'Recordármelo'"
[attr.aria-pressed]="!!issue.myReminder"
title="Avisarme de este ticket cuando yo diga"
(execute)="openReminder()"
/>
}
@if ( @if (
project.myRole() && project.myRole() &&
(facade.children().length || issue.followingTree) (facade.children().length || issue.followingTree)
@@ -265,6 +279,30 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
</div> </div>
</header> </header>
@if (issue.myReminder; as reminder) {
<p class="ts-reminder" role="status">
<span aria-hidden="true">⏰</span>
Te lo recordaré {{ when(reminder.at) }}
@if (reminder.note) {
· «{{ reminder.note }}»
}
<button
type="button"
class="ts-reminder__action"
(click)="openReminder()"
>
Cambiar
</button>
<button
type="button"
class="ts-reminder__action"
(click)="facade.setReminder(null)"
>
Quitar
</button>
</p>
}
@if (project.canContribute() && issue.allowedTransitions.length) { @if (project.canContribute() && issue.allowedTransitions.length) {
<div class="ts-transitions" role="group" aria-label="Mover a"> <div class="ts-transitions" role="group" aria-label="Mover a">
<span class="ts-eyebrow">Mover a</span> <span class="ts-eyebrow">Mover a</span>
@@ -1163,6 +1201,58 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<pi-skeleton shape="text" [lines]="8" /> <pi-skeleton shape="text" [lines]="8" />
} }
<ng-template #reminderForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); saveReminder(reminderAt())"
novalidate
>
<p class="ts-muted">
Te llegará un aviso (en la campana y, si los tienes activados, en este
dispositivo o por correo). Solo lo ves tú.
</p>
<div class="ts-reminder__choices" role="group" aria-label="Cuándo">
@for (choice of reminderChoices(); track choice.label) {
<button
type="button"
class="ts-reminder__choice"
(click)="saveReminder(choice.at)"
>
<span>{{ choice.label }}</span>
<span class="ts-muted">{{ when(choice.at) }}</span>
</button>
}
</div>
<label class="ts-reminder__field">
Otro momento
<input
class="ts-input"
type="datetime-local"
[min]="nowLocal()"
[value]="reminderAt()"
(input)="reminderAt.set($any($event.target).value)"
/>
</label>
<label class="ts-reminder__field">
Nota (opcional)
<input
class="ts-input"
maxlength="280"
placeholder="Llamar a QA antes de la release"
[value]="reminderNote()"
(input)="reminderNote.set($any($event.target).value)"
/>
</label>
<pi-action-button
size="w100"
color="primary"
text="Recordármelo"
[disabled]="!reminderAt()"
(execute)="saveReminder(reminderAt())"
/>
</form>
</ng-template>
<ng-template #reasonForm> <ng-template #reasonForm>
<form <form
class="ts-form" class="ts-form"
@@ -1264,6 +1354,13 @@ export class IssuePage {
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly reasonForm = private readonly reasonForm =
viewChild.required<TemplateRef<unknown>>('reasonForm'); viewChild.required<TemplateRef<unknown>>('reasonForm');
private readonly reminderForm =
viewChild.required<TemplateRef<unknown>>('reminderForm');
/** The reminder being written: `datetime-local` value and note. */
protected readonly reminderAt = signal('');
protected readonly reminderNote = signal('');
protected readonly reminderChoices = signal<ReminderChoice[]>([]);
protected readonly when = whenLabel;
private readonly editForm = private readonly editForm =
viewChild.required<TemplateRef<unknown>>('editForm'); viewChild.required<TemplateRef<unknown>>('editForm');
private readonly commentEditForm = private readonly commentEditForm =
@@ -1638,6 +1735,28 @@ export class IssuePage {
this.parentKey.set(null); 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 { protected move(to: string): void {
const open = this.openThreads(); const open = this.openThreads();
if ( if (
@@ -1934,3 +2053,9 @@ export class IssuePage {
this.drawer.closeAll(); 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())}`;
}