diff --git a/apps/api/openapi.json b/apps/api/openapi.json index b32e82e..7d71148 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -2776,6 +2776,294 @@ ] } }, + "/api/me/day": { + "get": { + "description": "And what was left unfinished on my latest earlier day.", + "operationId": "IssuesController_myDay", + "parameters": [ + { + "name": "day", + "required": true, + "in": "query", + "description": "Today, in my time zone", + "schema": { + "example": "2026-10-20", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MyDayDto" + } + } + } + }, + "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": "«Mi día»: the tickets I picked for today", + "tags": [ + "Issues" + ] + } + }, + "/api/me/day/{issue}": { + "put": { + "operationId": "IssuesController_addToMyDay", + "parameters": [ + { + "name": "issue", + "required": true, + "in": "path", + "description": "Ticket id or key", + "schema": { + "example": "WEB-12", + "type": "string" + } + }, + { + "name": "day", + "required": true, + "in": "query", + "description": "Today, in my time zone", + "schema": { + "example": "2026-10-20", + "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": "Add a ticket to «Mi día» (10 at most)", + "tags": [ + "Issues" + ] + }, + "delete": { + "operationId": "IssuesController_removeFromMyDay", + "parameters": [ + { + "name": "issue", + "required": true, + "in": "path", + "description": "Ticket id or key", + "schema": { + "example": "WEB-12", + "type": "string" + } + }, + { + "name": "day", + "required": true, + "in": "query", + "description": "Today, in my time zone", + "schema": { + "example": "2026-10-20", + "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": "Take a ticket out of «Mi día»", + "tags": [ + "Issues" + ] + } + }, "/api/me/reminders": { "get": { "operationId": "IssuesController_myReminders", @@ -16339,6 +16627,53 @@ "at" ] }, + "MyDayLeftoverDto": { + "type": "object", + "properties": { + "day": { + "type": "string" + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IssueDto" + } + } + }, + "required": [ + "day", + "issues" + ] + }, + "MyDayDto": { + "type": "object", + "properties": { + "day": { + "type": "string", + "description": "YYYY-MM-DD, in my time zone" + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IssueDto" + } + }, + "leftover": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MyDayLeftoverDto" + } + ] + } + }, + "required": [ + "day", + "issues", + "leftover" + ] + }, "IssueReminderDto": { "type": "object", "properties": { diff --git a/apps/api/schema.gql b/apps/api/schema.gql index 6602031..d679253 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -957,6 +957,9 @@ type Mutation { """Add a Pireon user to the project""" addProjectMember(input: ProjectMemberInput!): CommandResult! + """Add tickets to «Mi día» (10 at most)""" + addToMyDay(day: String!, issueIds: [ID!]!): Boolean! + """ Archive the project (owner only): everything stays readable, nothing changes until it is unarchived """ @@ -1073,6 +1076,9 @@ type Mutation { removeAttachment(attachmentId: ID!, issueId: ID!): CommandResult! removeChecklistItem(issueId: ID!, itemId: ID!): CommandResult! + """Take a ticket out of «Mi día»""" + removeFromMyDay(day: String!, issueId: ID!): Boolean! + """Remove a repository; what it linked stays on the tickets""" removeGitRepository(projectId: ID!, repositoryId: ID!): Boolean! removeProjectMember(projectId: ID!, userId: ID!): CommandResult! @@ -1210,6 +1216,22 @@ type Mutation { writeReleaseNotes(notes: String, releaseId: ID!): CommandResult! } +"""«Mi día»: the tickets I picked for today""" +type MyDay { + """YYYY-MM-DD, in my time zone""" + day: String! + + """In order, done ones included""" + issues: [Issue!]! + leftover: MyDayLeftover +} + +"""What was left unfinished on the latest earlier day""" +type MyDayLeftover { + day: String! + issues: [Issue!]! +} + type MyNotifications { items: [Notification!]! @@ -1625,6 +1647,14 @@ type Query { """My calendar URL, if I made one""" myCalendarFeed: CalendarFeed + """ + «Mi día»: the tickets I picked for today, and what was left on my last day + """ + myDay( + """Today, YYYY-MM-DD in my time zone""" + day: String! + ): MyDay! + """ The home page beyond my work: agenda of the week, activity of my projects, the week in numbers, sprints, project cards, suggestions, first steps and who is on what now """ diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 221b22b..129cdbf 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/day": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * «Mi día»: the tickets I picked for today + * @description And what was left unfinished on my latest earlier day. + */ + get: operations["IssuesController_myDay"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/me/day/{issue}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Add a ticket to «Mi día» (10 at most) */ + put: operations["IssuesController_addToMyDay"]; + post?: never; + /** Take a ticket out of «Mi día» */ + delete: operations["IssuesController_removeFromMyDay"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/me/reminders": { parameters: { query?: never; @@ -2951,6 +2989,16 @@ export interface components { */ at: string; }; + MyDayLeftoverDto: { + day: string; + issues: components["schemas"]["IssueDto"][]; + }; + MyDayDto: { + /** @description YYYY-MM-DD, in my time zone */ + day: string; + issues: components["schemas"]["IssueDto"][]; + leftover: components["schemas"]["MyDayLeftoverDto"] | null; + }; IssueReminderDto: { /** Format: date-time */ at: string; @@ -5809,6 +5857,218 @@ export interface operations { }; }; }; + IssuesController_myDay: { + parameters: { + query: { + /** @description Today, in my time zone */ + day: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MyDayDto"]; + }; + }; + /** @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_addToMyDay: { + parameters: { + query: { + /** @description Today, in my time zone */ + day: string; + }; + 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_removeFromMyDay: { + parameters: { + query: { + /** @description Today, in my time zone */ + day: string; + }; + 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_myReminders: { parameters: { query?: never; diff --git a/apps/mcp/src/app/tools/issues.resolver.ts b/apps/mcp/src/app/tools/issues.resolver.ts index e26385b..fbdc916 100644 --- a/apps/mcp/src/app/tools/issues.resolver.ts +++ b/apps/mcp/src/app/tools/issues.resolver.ts @@ -783,6 +783,50 @@ export class IssuesResolver { ); } + @Tool({ + name: 'my_day', + description: + '«Mi día»: the tickets I picked for today (done ones marked), and what was left unfinished on my last day. Optionally add or remove one first. day is today in my time zone, YYYY-MM-DD.', + paramsSchema: z.object({ + day: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + add: issueRef.optional(), + remove: issueRef.optional(), + }), + annotations: WRITE, + }) + myDay( + params: { day: string; add?: string; remove?: string }, + ctx: McpContext, + ) { + const client = this.api.as(ctx); + const query = { day: params.day }; + return toolResult(async () => { + if (params.add) + await this.api.data( + client.PUT('/api/me/day/{issue}', { + params: { path: { issue: params.add }, query }, + }), + ); + if (params.remove) + await this.api.data( + client.DELETE('/api/me/day/{issue}', { + params: { path: { issue: params.remove }, query }, + }), + ); + const myDay = await this.api.data( + client.GET('/api/me/day', { params: { query } }), + ); + return { + day: myDay.day, + issues: myDay.issues.map(issueLine), + leftover: myDay.leftover && { + day: myDay.leftover.day, + issues: myDay.leftover.issues.map(issueLine), + }, + }; + }); + } + @Tool({ name: 'remind_me', description: diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index b37068f..e3f5668 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -18,6 +18,7 @@ | 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto | | 📋 Lista | Todos los tickets en filas: ordenar por cualquier columna, filtrar, cambiar estado, prioridad o persona sin abrirlos, editar en bloque y duplicar | | ↩️ Deshacer | Mover, reasignar o mandar a la papelera ofrece «Deshacer» unos segundos (también Ctrl/⌘ Z), con la orden contraria | +| ☀ Mi día | Cada mañana eliges los tickets de hoy; se tachan al terminar y lo que quede se pasa al día siguiente; y «Recordármelo» en cualquier ticket | | 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo | | 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media | | 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar | diff --git a/docs/03-api.md b/docs/03-api.md index cbaf4a1..aef7f16 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -425,7 +425,11 @@ eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien lo abre, quien lo ti sigue un ticket (`WATCHED`) sabe cuándo cambia de estado, se asigna, se comenta, cambia su prioridad o su fecha límite, 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). **Recordármelo**: cada persona pone en un ticket un recordatorio con fecha y hora y nota opcional +`?watching=true`; sin sesión, ninguno). **Mi día**: los tickets que cada persona elige para hoy (`myDay(day)`, `addToMyDay(day, issueIds)`, +`removeFromMyDay(day, issueId)`; `GET /api/me/day?day=`, `PUT/DELETE /api/me/day/{issue}?day=`), hasta 10. `day` es +hoy en la zona horaria de quien pregunta (`YYYY-MM-DD`, a dos días como mucho de la del servidor). La respuesta trae +también lo que quedó sin terminar el último día anterior (si fue en la última semana), para pasarlo a hoy. Privado y sin +historial (`my_days`). **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 @@ -549,17 +553,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`, `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` | +| Á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`, `addToMyDay`, `removeFromMyDay` (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 @@ -757,25 +761,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`, `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` | +| 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/day`, `PUT/DELETE /api/me/day/{issue}`, `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 08fae68..15ec6d6 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). «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 | +| 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), «Mi día» (`ts-my-day`: los tickets que elijo para hoy, tachados al terminar, mis abiertos para añadir y lo que quedó del último día), 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. «☀ Mi día» lo añade a los de hoy | +| `/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 ac081cc..c2a12bf 100644 --- a/docs/09-mcp.md +++ b/docs/09-mcp.md @@ -69,6 +69,7 @@ flowchart LR | | `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) | +| | `my_day` | Mi día: los tickets elegidos para hoy (y lo que quedó del último día); añadir o quitar uno | | | `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 0c6f590..150e750 100644 --- a/libs/api/tracker/application/src/index.ts +++ b/libs/api/tracker/application/src/index.ts @@ -46,6 +46,9 @@ 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/my-day/my-day'; +export * from './lib/ports/my-days.repository'; +export * from './lib/my-day.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/my-day.service.ts b/libs/api/tracker/application/src/lib/my-day.service.ts new file mode 100644 index 0000000..1b76de7 --- /dev/null +++ b/libs/api/tracker/application/src/lib/my-day.service.ts @@ -0,0 +1,32 @@ +import { CommandRunner } from '@task-stream/api/shared/application'; +import { Injectable } from '@nestjs/common'; +import { QueryBus } from '@nestjs/cqrs'; +import { + AddToMyDayCommand, + GetMyDayQuery, + MyDay, + RemoveFromMyDayCommand, +} from './my-day/my-day'; + +/** «Mi día»: the tickets I picked for today. */ +@Injectable() +export class MyDayService { + constructor( + private readonly commands: CommandRunner, + private readonly queryBus: QueryBus, + ) {} + + get(userId: string, day: string): Promise { + return this.queryBus.execute(new GetMyDayQuery(userId, day)); + } + + add(userId: string, day: string, issueIds: string[]): Promise { + return this.commands.execute(new AddToMyDayCommand(userId, day, issueIds)); + } + + remove(userId: string, day: string, issueId: string): Promise { + return this.commands.execute( + new RemoveFromMyDayCommand(userId, day, issueId), + ); + } +} diff --git a/libs/api/tracker/application/src/lib/my-day/my-day.spec.ts b/libs/api/tracker/application/src/lib/my-day/my-day.spec.ts new file mode 100644 index 0000000..f4f409f --- /dev/null +++ b/libs/api/tracker/application/src/lib/my-day/my-day.spec.ts @@ -0,0 +1,145 @@ +import { + CommandBus, + CqrsModule, + IQueryHandler, + QueryBus, + QueryHandler, +} from '@nestjs/cqrs'; +import { Test } from '@nestjs/testing'; +import { + BusinessRuleError, + NotFoundError, + ValidationError, +} from '@task-stream/api/shared/domain'; +import { + IssueReadModel, + StatusCategory, +} from '@task-stream/api/tracker/domain'; +import { MyDayRecord, MyDaysRepository } from '../ports/my-days.repository'; +import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query'; +import { + AddToMyDayCommand, + GetMyDayQuery, + MAX_MY_DAY, + MY_DAY, + MyDay, + RemoveFromMyDayCommand, +} from './my-day'; + +class InMemoryDays extends MyDaysRepository { + rows: MyDayRecord[] = []; + async find(userId: string, day: string) { + return this.rows.find((r) => r.userId === userId && r.day === day) ?? null; + } + async latestBefore(userId: string, day: string) { + return ( + this.rows + .filter((r) => r.userId === userId && r.day < day) + .sort((a, b) => b.day.localeCompare(a.day))[0] ?? null + ); + } + async save(record: MyDayRecord) { + this.rows = [ + ...this.rows.filter( + (r) => !(r.userId === record.userId && r.day === record.day), + ), + record, + ]; + } +} + +/** Ana reads i0…i11; i3 is done. */ +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 day = (offset: number) => + new Date(Date.now() + offset * 86_400_000).toISOString().slice(0, 10); + +async function setup() { + issues.clear(); + for (let i = 0; i < 12; i++) + issues.set(`i${i}`, { + id: `i${i}`, + key: `WEB-${i}`, + statusCategory: i === 3 ? StatusCategory.DONE : StatusCategory.TODO, + trashedAt: null, + } as IssueReadModel); + const rows = new InMemoryDays(); + const moduleRef = await Test.createTestingModule({ + imports: [CqrsModule.forRoot()], + providers: [ + ...MY_DAY, + FakeIssuesByIds, + { provide: MyDaysRepository, useValue: rows }, + ], + }).compile(); + await moduleRef.init(); + const commands = moduleRef.get(CommandBus); + const queries = moduleRef.get(QueryBus); + return { + rows, + commands, + keys: async (d: string) => { + const myDay: MyDay = await queries.execute(new GetMyDayQuery('ana', d)); + return { + issues: myDay.issues.map((i) => i.key), + leftover: myDay.leftover?.issues.map((i) => i.key) ?? null, + }; + }, + }; +} + +describe('Mi día', () => { + it('keeps the tickets picked for today, in order, once each', async () => { + const { commands, keys } = await setup(); + await commands.execute(new AddToMyDayCommand('ana', day(0), ['i1', 'i2'])); + await commands.execute(new AddToMyDayCommand('ana', day(0), ['i2', 'i3'])); + await commands.execute(new RemoveFromMyDayCommand('ana', day(0), 'i1')); + expect((await keys(day(0))).issues).toEqual(['WEB-2', 'WEB-3']); + }); + + it(`takes ${MAX_MY_DAY} at most, only readable tickets, only around today`, async () => { + const { commands } = await setup(); + const many = Array.from({ length: MAX_MY_DAY + 1 }, (_, i) => `i${i}`); + await expect( + commands.execute(new AddToMyDayCommand('ana', day(0), many)), + ).rejects.toBeInstanceOf(BusinessRuleError); + await expect( + commands.execute(new AddToMyDayCommand('ana', day(0), ['nope'])), + ).rejects.toBeInstanceOf(NotFoundError); + await expect( + commands.execute(new AddToMyDayCommand('ana', day(5), ['i1'])), + ).rejects.toBeInstanceOf(ValidationError); + await expect( + commands.execute(new AddToMyDayCommand('ana', '20-10-2026', ['i1'])), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it('offers what was left unfinished on the last day, but not the done ones', async () => { + const { commands, rows, keys } = await setup(); + rows.rows.push({ + userId: 'ana', + day: day(-1), + issueIds: ['i1', 'i3', 'i4'], + updatedAt: '', + }); + expect(await keys(day(0))).toEqual({ + issues: [], + leftover: ['WEB-1', 'WEB-4'], + }); + // Carried over: no longer offered. + await commands.execute(new AddToMyDayCommand('ana', day(0), ['i1', 'i4'])); + expect(await keys(day(0))).toEqual({ + issues: ['WEB-1', 'WEB-4'], + leftover: null, + }); + }); +}); diff --git a/libs/api/tracker/application/src/lib/my-day/my-day.ts b/libs/api/tracker/application/src/lib/my-day/my-day.ts new file mode 100644 index 0000000..99797ad --- /dev/null +++ b/libs/api/tracker/application/src/lib/my-day/my-day.ts @@ -0,0 +1,194 @@ +import { + Command, + CommandHandler, + ICommandHandler, + IQueryHandler, + Query, + QueryBus, + QueryHandler, +} from '@nestjs/cqrs'; +import { + BusinessRuleError, + NotFoundError, + assertThat, +} from '@task-stream/api/shared/domain'; +import { + IssueReadModel, + StatusCategory, +} from '@task-stream/api/tracker/domain'; +import { MyDayRecord, MyDaysRepository } from '../ports/my-days.repository'; +import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query'; + +/* + * «Mi día»: the few tickets someone picks each morning for today, and at + * the end what got done. The day is the person's own (`YYYY-MM-DD` from + * their browser). Private, no history. + */ + +export const MAX_MY_DAY = 10; +/** How far back what was left counts as «from before». */ +const CARRY_OVER_DAYS = 7; + +const DAY_FORMAT = /^\d{4}-\d{2}-\d{2}$/; +const DAY_MS = 86_400_000; + +const finished = (issue: IssueReadModel) => + issue.statusCategory === StatusCategory.DONE || + issue.statusCategory === StatusCategory.CANCELLED; + +/** A day of the person's: well formed and near today (time zones apart). */ +export function assertDay(day: string, now = Date.now()): void { + const at = Date.parse(`${day}T12:00:00Z`); + assertThat(DAY_FORMAT.test(day) && !Number.isNaN(at), 'A day is YYYY-MM-DD'); + assertThat( + Math.abs(at - now) <= 2 * DAY_MS, + 'Only today can be planned (in your time zone)', + ); +} + +export interface MyDay { + day: string; + /** Picked for the day, in order (done ones included). */ + issues: IssueReadModel[]; + /** What was left unfinished on the latest earlier day, if recent. */ + leftover: { day: string; issues: IssueReadModel[] } | null; +} + +export class AddToMyDayCommand extends Command { + constructor( + public readonly userId: string, + public readonly day: string, + /** Several at once: carrying over what was left. */ + public readonly issueIds: string[], + ) { + super(); + } +} + +export class RemoveFromMyDayCommand extends Command { + constructor( + public readonly userId: string, + public readonly day: string, + public readonly issueId: string, + ) { + super(); + } +} + +export class GetMyDayQuery extends Query { + constructor( + public readonly userId: string, + public readonly day: string, + ) { + super(); + } +} + +@CommandHandler(AddToMyDayCommand) +export class AddToMyDayCommandHandler + implements ICommandHandler +{ + constructor( + private readonly days: MyDaysRepository, + private readonly queryBus: QueryBus, + ) {} + + async execute(command: AddToMyDayCommand): Promise { + assertDay(command.day); + const wanted = [...new Set(command.issueIds)]; + const readable: IssueReadModel[] = await this.queryBus.execute( + new GetIssuesByIdsQuery(command.userId, wanted), + ); + const ok = new Set(readable.filter((i) => !i.trashedAt).map((i) => i.id)); + // One I cannot read does not exist, as far as I know. + const missing = wanted.find((id) => !ok.has(id)); + if (missing) throw new NotFoundError('Issue', missing); + const current = await this.days.find(command.userId, command.day); + const issueIds = [ + ...(current?.issueIds ?? []), + ...wanted.filter((id) => !current?.issueIds.includes(id)), + ]; + if (issueIds.length > MAX_MY_DAY) + throw new BusinessRuleError( + `Mi día takes ${MAX_MY_DAY} tickets at most: finish or take some out first`, + ); + await this.days.save({ + userId: command.userId, + day: command.day, + issueIds, + updatedAt: new Date().toISOString(), + }); + } +} + +@CommandHandler(RemoveFromMyDayCommand) +export class RemoveFromMyDayCommandHandler + implements ICommandHandler +{ + constructor(private readonly days: MyDaysRepository) {} + + async execute(command: RemoveFromMyDayCommand): Promise { + assertDay(command.day); + const current = await this.days.find(command.userId, command.day); + if (!current?.issueIds.includes(command.issueId)) return; + await this.days.save({ + ...current, + issueIds: current.issueIds.filter((id) => id !== command.issueId), + updatedAt: new Date().toISOString(), + }); + } +} + +@QueryHandler(GetMyDayQuery) +export class GetMyDayQueryHandler + implements IQueryHandler +{ + constructor( + private readonly days: MyDaysRepository, + private readonly queryBus: QueryBus, + ) {} + + async execute(query: GetMyDayQuery): Promise { + assertDay(query.day); + const [today, before] = await Promise.all([ + this.days.find(query.userId, query.day), + this.days.latestBefore(query.userId, query.day), + ]); + const recent = + before && + Date.parse(`${query.day}T00:00:00Z`) - + Date.parse(`${before.day}T00:00:00Z`) <= + CARRY_OVER_DAYS * DAY_MS + ? before + : null; + const ids = [ + ...new Set([...(today?.issueIds ?? []), ...(recent?.issueIds ?? [])]), + ]; + const found: IssueReadModel[] = ids.length + ? await this.queryBus.execute(new GetIssuesByIdsQuery(query.userId, ids)) + : []; + const byId = new Map( + found.filter((i) => !i.trashedAt).map((i) => [i.id, i]), + ); + const pick = (record: MyDayRecord | null) => + (record?.issueIds ?? []).flatMap((id) => byId.get(id) ?? []); + const issues = pick(today); + const leftover = pick(recent).filter( + (i) => !finished(i) && !today?.issueIds.includes(i.id), + ); + return { + day: query.day, + issues, + leftover: + recent && leftover.length + ? { day: recent.day, issues: leftover } + : null, + }; + } +} + +export const MY_DAY = [ + AddToMyDayCommandHandler, + RemoveFromMyDayCommandHandler, + GetMyDayQueryHandler, +]; diff --git a/libs/api/tracker/application/src/lib/ports/my-days.repository.ts b/libs/api/tracker/application/src/lib/ports/my-days.repository.ts new file mode 100644 index 0000000..fa06bdb --- /dev/null +++ b/libs/api/tracker/application/src/lib/ports/my-days.repository.ts @@ -0,0 +1,19 @@ +/** The tickets someone picked for one day of theirs. */ +export interface MyDayRecord { + userId: string; + /** `YYYY-MM-DD` in the person's own time zone. */ + day: string; + /** In the order they were picked. */ + issueIds: string[]; + updatedAt: string; +} + +export abstract class MyDaysRepository { + abstract find(userId: string, day: string): Promise; + /** The latest day before `day`, if any (to carry over what was left). */ + abstract latestBefore( + userId: string, + day: string, + ): Promise; + abstract save(record: MyDayRecord): Promise; +} 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 199df64..6b4d179 100644 --- a/libs/api/tracker/application/src/lib/tracker-application.module.ts +++ b/libs/api/tracker/application/src/lib/tracker-application.module.ts @@ -39,6 +39,8 @@ 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 { MY_DAY } from './my-day/my-day'; +import { MyDayService } from './my-day.service'; import { IssueRemindersService } from './issue-reminders.service'; import { TreeFollowsService } from './tree-follows.service'; import { AlertsService } from './alerts.service'; @@ -90,6 +92,7 @@ const SERVICES = [ AlertsService, TreeFollowsService, IssueRemindersService, + MyDayService, SprintsService, GitService, WebhooksService, @@ -124,6 +127,7 @@ const SERVICES = [ ...ALERT_HANDLERS, ...TREE_FOLLOWS, ...ISSUE_REMINDERS, + ...MY_DAY, ...GIT_HANDLERS, ...WEBHOOK_HANDLERS, ...TRANSFER_HANDLERS, diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/my-days.repository.ts b/libs/api/tracker/infrastructure/src/lib/mongo/my-days.repository.ts new file mode 100644 index 0000000..4e620eb --- /dev/null +++ b/libs/api/tracker/infrastructure/src/lib/mongo/my-days.repository.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { + MyDayRecord, + MyDaysRepository, +} from '@task-stream/api/tracker/application'; +import { Model } from 'mongoose'; +import { ReadModels } from './read-model.schemas'; + +type Stored = MyDayRecord & { _id?: unknown }; + +const strip = ({ _id: _ignored, ...record }: Stored): MyDayRecord => ({ + ...record, + // Mongo leaves an emptied list out. + issueIds: record.issueIds ?? [], +}); + +@Injectable() +export class MongoMyDaysRepository extends MyDaysRepository { + constructor( + @InjectModel(ReadModels.MyDay) private readonly days: Model, + ) { + super(); + } + + async find(userId: string, day: string): Promise { + const doc = await this.days.findOne({ userId, day }).lean(); + return doc ? strip(doc) : null; + } + + async latestBefore(userId: string, day: string): Promise { + const doc = await this.days + .findOne({ userId, day: { $lt: day } }) + .sort({ day: -1 }) + .lean(); + return doc ? strip(doc) : null; + } + + async save(record: MyDayRecord): Promise { + await this.days.updateOne( + { userId: record.userId, day: record.day }, + { $set: record }, + { upsert: true }, + ); + } +} 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 64318fd..5680165 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 @@ -111,6 +111,7 @@ export const ReadModels = { AlertTrace: 'AlertTrace', TreeFollow: 'TreeFollow', IssueReminder: 'IssueReminder', + MyDay: 'MyDay', Sprint: 'SprintReadModel', GitIntegration: 'GitIntegration', OutgoingWebhook: 'OutgoingWebhook', @@ -385,6 +386,15 @@ export const IssueReminderSchema = SchemaFactory.createForClass( IssueReminderSchema.index({ sentAt: 1, at: 1 }); IssueReminderSchema.index({ userId: 1, issueId: 1 }); +@Schema({ ...LOOSE, collection: 'my_days' }) +class MyDayDocument { + @Prop({ type: String }) userId!: string; + @Prop({ type: String }) day!: string; + @Prop({ type: [String] }) issueIds!: string[]; +} +export const MyDaySchema = SchemaFactory.createForClass(MyDayDocument); +MyDaySchema.index({ userId: 1, day: -1 }, { unique: true }); + @Schema({ ...LOOSE, collection: 'git_integrations' }) class GitIntegrationDocument { /** The project's id. */ @@ -492,6 +502,7 @@ export const READ_MODEL_SCHEMAS = [ { name: ReadModels.AlertTrace, schema: AlertTraceSchema }, { name: ReadModels.TreeFollow, schema: TreeFollowSchema }, { name: ReadModels.IssueReminder, schema: IssueReminderSchema }, + { name: ReadModels.MyDay, schema: MyDaySchema }, { 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 fc053a4..ae3ce1c 100644 --- a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts +++ b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts @@ -17,6 +17,7 @@ import { AlertIntegrationsRepository, TreeFollowsRepository, IssueRemindersRepository, + MyDaysRepository, GitIntegrationsRepository, OutgoingWebhooksRepository, AutomationsRepository, @@ -86,6 +87,7 @@ 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 { MongoMyDaysRepository } from './mongo/my-days.repository'; import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository'; import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository'; import { HttpWebhookSender } from './webhooks/http-webhook.sender'; @@ -172,6 +174,7 @@ const PORTS = [ provide: IssueRemindersRepository, useClass: MongoIssueRemindersRepository, }, + { provide: MyDaysRepository, useClass: MongoMyDaysRepository }, { 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 dd195a4..616654b 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts @@ -20,6 +20,7 @@ import { PinsService, TreeFollowsService, IssueRemindersService, + MyDayService, PresenceService, WorkflowsService, } from '@task-stream/api/tracker/application'; @@ -54,6 +55,7 @@ import { IssuePageGql, IssueParentGql, IssueReminderGql, + MyDayGql, ReminderOnIssueGql, PresenceGql, } from './types'; @@ -69,6 +71,7 @@ export class IssueResolver { private readonly pins: PinsService, private readonly treeFollows: TreeFollowsService, private readonly reminders: IssueRemindersService, + private readonly myDayService: MyDayService, ) {} @Mutation(() => [PresenceGql], { @@ -442,6 +445,40 @@ export class IssueResolver { return true; } + @Query(() => MyDayGql, { + description: + '«Mi día»: the tickets I picked for today, and what was left on my last day', + }) + myDay( + @CurrentUser() me: User, + @Args('day', { description: 'Today, YYYY-MM-DD in my time zone' }) + day: string, + ) { + return this.myDayService.get(me.id, day); + } + + @Mutation(() => Boolean, { + description: 'Add tickets to «Mi día» (10 at most)', + }) + async addToMyDay( + @CurrentUser() me: User, + @Args('day') day: string, + @Args('issueIds', { type: () => [ID] }) issueIds: string[], + ): Promise { + await this.myDayService.add(me.id, day, issueIds); + return true; + } + + @Mutation(() => Boolean, { description: 'Take a ticket out of «Mi día»' }) + async removeFromMyDay( + @CurrentUser() me: User, + @Args('day') day: string, + @Args('issueId', { type: () => ID }) issueId: string, + ): Promise { + await this.myDayService.remove(me.id, day, issueId); + return true; + } + @Query(() => [ReminderOnIssueGql], { description: 'My waiting reminders on tickets, soonest first', }) diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index 21e58ca..8bf2cfe 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -1393,3 +1393,22 @@ export class ReminderOnIssueGql { @Field(() => IssueReminderGql) reminder!: IssueReminderGql; @Field(() => IssueGql) issue!: IssueGql; } + +@ObjectType('MyDayLeftover', { + description: 'What was left unfinished on the latest earlier day', +}) +export class MyDayLeftoverGql { + @Field() day!: string; + @Field(() => [IssueGql]) issues!: IssueGql[]; +} + +@ObjectType('MyDay', { + description: '«Mi día»: the tickets I picked for today', +}) +export class MyDayGql { + @Field({ description: 'YYYY-MM-DD, in my time zone' }) day!: string; + @Field(() => [IssueGql], { description: 'In order, done ones included' }) + issues!: IssueGql[]; + @Field(() => MyDayLeftoverGql, { nullable: true }) + leftover!: MyDayLeftoverGql | null; +} 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 a49432f..0fbf706 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,12 @@ export class PushEndpointDto { endpoint!: string; } +export class MyDayQueryDto { + @ApiProperty({ example: '2026-10-20', description: 'Today, in my time zone' }) + @Matches(/^\d{4}-\d{2}-\d{2}$/) + day!: string; +} + export class SetReminderDto { @ApiProperty({ format: 'date-time', 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 31a132c..0f49bf5 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 @@ -1427,3 +1427,15 @@ export class ReminderOnIssueDto { @ApiProperty({ type: IssueReminderDto }) reminder!: IssueReminderDto; @ApiProperty({ type: () => IssueDto }) issue!: IssueDto; } + +export class MyDayLeftoverDto { + @ApiProperty() day!: string; + @ApiProperty({ type: () => [IssueDto] }) issues!: IssueDto[]; +} + +export class MyDayDto { + @ApiProperty({ description: 'YYYY-MM-DD, in my time zone' }) day!: string; + @ApiProperty({ type: () => [IssueDto] }) issues!: IssueDto[]; + @ApiProperty({ type: MyDayLeftoverDto, nullable: true }) + leftover!: MyDayLeftoverDto | null; +} 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 93d8255..5003a77 100644 --- a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts @@ -35,6 +35,7 @@ import { PinsService, TreeFollowsService, IssueRemindersService, + MyDayService, PresenceService, ProjectsService, } from '@task-stream/api/tracker/application'; @@ -49,6 +50,7 @@ import { CreateIssueDto, DecisionsQueryDto, SetReminderDto, + MyDayQueryDto, IssueSearchQueryDto, PresenceBodyDto, LinkIssueDto, @@ -69,6 +71,7 @@ import { CommentPageDto, DecisionDto, IssueReminderDto, + MyDayDto, ReminderOnIssueDto, EpicDto, IssueDto, @@ -106,6 +109,7 @@ export class IssuesController { private readonly pins: PinsService, private readonly treeFollows: TreeFollowsService, private readonly reminders: IssueRemindersService, + private readonly myDayService: MyDayService, ) {} @Get('me/home') @@ -210,6 +214,59 @@ export class IssuesController { await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), false); } + @Get('me/day') + @ApiOperation({ + summary: '«Mi día»: the tickets I picked for today', + description: 'And what was left unfinished on my latest earlier day.', + }) + @ApiOkResponse({ type: MyDayDto }) + @ApiReadErrors() + async myDay(@CurrentUser() me: User, @Query() query: MyDayQueryDto) { + const myDay = await this.myDayService.get(me.id, query.day); + return { + day: myDay.day, + issues: await this.refs.issueDtos(myDay.issues, viewerOf(me)), + leftover: myDay.leftover && { + day: myDay.leftover.day, + issues: await this.refs.issueDtos(myDay.leftover.issues, viewerOf(me)), + }, + }; + } + + @Put('me/day/:issue') + @HttpCode(204) + @ISSUE + @ApiOperation({ summary: 'Add a ticket to «Mi día» (10 at most)' }) + @ApiNoContentResponse() + @ApiCommandErrors() + async addToMyDay( + @CurrentUser() me: User, + @Param('issue') issue: string, + @Query() query: MyDayQueryDto, + ) { + await this.myDayService.add(me.id, query.day, [ + await this.refs.issueId(me.id, issue), + ]); + } + + @Delete('me/day/:issue') + @HttpCode(204) + @ISSUE + @ApiOperation({ summary: 'Take a ticket out of «Mi día»' }) + @ApiNoContentResponse() + @ApiCommandErrors() + async removeFromMyDay( + @CurrentUser() me: User, + @Param('issue') issue: string, + @Query() query: MyDayQueryDto, + ) { + await this.myDayService.remove( + me.id, + query.day, + await this.refs.issueId(me.id, issue), + ); + } + @Get('me/reminders') @ApiOperation({ summary: 'My waiting reminders on tickets, soonest first' }) @ApiOkResponse({ type: [ReminderOnIssueDto] }) diff --git a/libs/web/tracker/domain/src/index.ts b/libs/web/tracker/domain/src/index.ts index 744a203..5a8874f 100644 --- a/libs/web/tracker/domain/src/index.ts +++ b/libs/web/tracker/domain/src/index.ts @@ -2,6 +2,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/my-day'; 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/my-day.ts b/libs/web/tracker/domain/src/lib/models/my-day.ts new file mode 100644 index 0000000..d2f567a --- /dev/null +++ b/libs/web/tracker/domain/src/lib/models/my-day.ts @@ -0,0 +1,11 @@ +import { IssueSummary } from './models'; + +/** «Mi día»: the tickets I picked for today. */ +export interface MyDay { + /** `YYYY-MM-DD` in my time zone. */ + day: string; + /** In order, done ones included. */ + issues: IssueSummary[]; + /** What was left unfinished on my latest earlier day. */ + leftover: { day: string; issues: IssueSummary[] } | null; +} diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index 1dd34ea..653685c 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -1,4 +1,5 @@ import { IssuePriority } from '../models/enums'; +import { MyDay } from '../models/my-day'; import { Home } from '../models/home'; import { PortfolioProject, ProjectInsights } from '../models/insights'; import { @@ -157,6 +158,10 @@ export abstract class IssuesGateway { note: string | null, ): Observable; abstract clearReminder(issueId: string): Observable; + /** «Mi día» on `day` (today, `YYYY-MM-DD` in my time zone). */ + abstract myDay(day: string): Observable; + abstract addToMyDay(day: string, issueIds: string[]): Observable; + abstract removeFromMyDay(day: string, 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 27f49ae..4875d25 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 @@ -16,6 +16,7 @@ import { IssueListFilter, IssueSummary, LinkIssuesInput, + MyDay, MyIssuesFilter, mergeBulkResults, UpdateIssueInput, @@ -53,6 +54,9 @@ import { MyPinsDocument, FollowTreeDocument, SetReminderDocument, + MyDayDocument, + AddToMyDayDocument, + RemoveFromMyDayDocument, ClearReminderDocument, UnfollowTreeDocument, SearchDecisionsDocument, @@ -232,6 +236,31 @@ export class GraphqlIssuesGateway extends IssuesGateway { .pipe(map(() => undefined)); } + myDay(day: string): Observable { + return this.gql.query(MyDayDocument, { day }).pipe( + map(({ myDay }) => ({ + day: myDay.day, + issues: myDay.issues.map(toIssueSummary), + leftover: myDay.leftover && { + day: myDay.leftover.day, + issues: myDay.leftover.issues.map(toIssueSummary), + }, + })), + ); + } + + addToMyDay(day: string, issueIds: string[]): Observable { + return this.gql + .mutate(AddToMyDayDocument, { day, issueIds }) + .pipe(map(() => undefined)); + } + + removeFromMyDay(day: string, issueId: string): Observable { + return this.gql + .mutate(RemoveFromMyDayDocument, { day, issueId }) + .pipe(map(() => undefined)); + } + clearReminder(issueId: string): Observable { return this.gql .mutate(ClearReminderDocument, { issueId }) diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index 4338b92..6b82ba3 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -1155,6 +1155,91 @@ export type MyPinsQuery = { }>; }; +export type MyDayQueryVariables = Exact<{ + day: string; +}>; + +export type MyDayQuery = { + myDay: { + day: string; + issues: Array<{ + id: string; + projectId: string; + key: string; + type: string; + area: string | null; + team: string | null; + title: string; + status: string; + statusCategory: StatusCategory; + priority: IssuePriority; + labels: Array; + commentCount: number; + updatedAt: string; + version: number; + votes: number; + voted: boolean; + rank: string; + storyPoints: number | null; + startDate: string | null; + dueDate: string | null; + trashedAt: string | null; + assignee: { id: string; name: string } | null; + release: { id: string; name: string } | null; + sprint: { id: string; name: string } | null; + parent: { id: string; key: string; title: string } | null; + blockedBy: Array<{ id: string; key: string; title: string }>; + customFields: Array<{ fieldId: string; value: string }>; + }>; + leftover: { + day: string; + issues: Array<{ + id: string; + projectId: string; + key: string; + type: string; + area: string | null; + team: string | null; + title: string; + status: string; + statusCategory: StatusCategory; + priority: IssuePriority; + labels: Array; + commentCount: number; + updatedAt: string; + version: number; + votes: number; + voted: boolean; + rank: string; + storyPoints: number | null; + startDate: string | null; + dueDate: string | null; + trashedAt: string | null; + assignee: { id: string; name: string } | null; + release: { id: string; name: string } | null; + sprint: { id: string; name: string } | null; + parent: { id: string; key: string; title: string } | null; + blockedBy: Array<{ id: string; key: string; title: string }>; + customFields: Array<{ fieldId: string; value: string }>; + }>; + } | null; + }; +}; + +export type AddToMyDayMutationVariables = Exact<{ + day: string; + issueIds: Array | string; +}>; + +export type AddToMyDayMutation = { addToMyDay: boolean }; + +export type RemoveFromMyDayMutationVariables = Exact<{ + day: string; + issueId: string; +}>; + +export type RemoveFromMyDayMutation = { removeFromMyDay: boolean }; + export type SetReminderMutationVariables = Exact<{ issueId: string; at: string; @@ -7507,6 +7592,346 @@ export const MyPinsDocument = { }, ], } as unknown as DocumentNode; +export const MyDayDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'MyDay' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'day' } }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'String' }, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'myDay' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'day' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'day' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'day' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'issues' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'IssueSummaryFields' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'leftover' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'day' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'issues' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { + kind: 'Name', + value: 'IssueSummaryFields', + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'UserRefFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'UserRef' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'IssueSummaryFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Issue' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'projectId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'type' } }, + { kind: 'Field', name: { kind: 'Name', value: 'area' } }, + { kind: 'Field', name: { kind: 'Name', value: 'team' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, + { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, + { kind: 'Field', name: { kind: 'Name', value: 'priority' } }, + { kind: 'Field', name: { kind: 'Name', value: 'labels' } }, + { kind: 'Field', name: { kind: 'Name', value: 'commentCount' } }, + { kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } }, + { kind: 'Field', name: { kind: 'Name', value: 'version' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'assignee' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'release' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'sprint' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'parent' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'blockedBy' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + ], + }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'votes' } }, + { kind: 'Field', name: { kind: 'Name', value: 'voted' } }, + { kind: 'Field', name: { kind: 'Name', value: 'rank' } }, + { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, + { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, + { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'customFields' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'fieldId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'value' } }, + ], + }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const AddToMyDayDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'AddToMyDay' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'day' } }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'String' }, + }, + }, + }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueIds' }, + }, + type: { + kind: 'NonNullType', + type: { + kind: 'ListType', + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'ID' }, + }, + }, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'addToMyDay' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'day' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'day' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'issueIds' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueIds' }, + }, + }, + ], + }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const RemoveFromMyDayDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'RemoveFromMyDay' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'day' } }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'String' }, + }, + }, + }, + { + 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: 'removeFromMyDay' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'day' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'day' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'issueId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + }, + ], + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + RemoveFromMyDayMutation, + RemoveFromMyDayMutationVariables +>; export const SetReminderDocument = { 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 63d9b0a..643bb73 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql @@ -101,6 +101,29 @@ query MyPins { } } +query MyDay($day: String!) { + myDay(day: $day) { + day + issues { + ...IssueSummaryFields + } + leftover { + day + issues { + ...IssueSummaryFields + } + } + } +} + +mutation AddToMyDay($day: String!, $issueIds: [ID!]!) { + addToMyDay(day: $day, issueIds: $issueIds) +} + +mutation RemoveFromMyDay($day: String!, $issueId: ID!) { + removeFromMyDay(day: $day, issueId: $issueId) +} + mutation SetReminder($issueId: ID!, $at: String!, $note: String) { setReminder(issueId: $issueId, at: $at, note: $note) { at 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 86f89ae..922b6dd 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts @@ -52,6 +52,8 @@ import { IssuePriority, isTyping, POINT_SCALE, + IssuesGateway, + localDay, ReleaseStatus, ReminderChoice, reminderChoices, @@ -209,6 +211,21 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; (execute)="facade.setWatching(!facade.watching())" /> } + @if (project.myRole() && inMyDay() !== null) { + + } @if (project.myRole()) { c.id === id)) untracked(() => setTimeout(() => this.goToComment(id))); }); + // Whether it is in today's «Mi día», once per ticket. + effect(() => { + const issueId = this.openIssueId(); + if (!issueId) return; + untracked(() => + this.issuesGateway.myDay(localDay(new Date())).subscribe({ + next: (myDay) => + this.inMyDay.set(myDay.issues.some((i) => i.id === issueId)), + error: () => this.inMyDay.set(null), + }), + ); + }); // Writing an answer or a comment: the others see it as I type. effect(() => { if (this.replyBody().trim()) @@ -1735,6 +1764,34 @@ export class IssuePage { this.parentKey.set(null); } + /** Whether it is in today's «Mi día» (null: not known yet). */ + protected readonly inMyDay = signal(null); + /** Changes with the ticket, not with each reload of it. */ + private readonly openIssueId = computed(() => this.facade.issue()?.id); + private readonly issuesGateway = inject(IssuesGateway); + + protected toggleMyDay(issue: Issue): void { + const day = localDay(new Date()); + const on = !this.inMyDay(); + this.inMyDay.set(on); + (on + ? this.issuesGateway.addToMyDay(day, [issue.id]) + : this.issuesGateway.removeFromMyDay(day, issue.id) + ).subscribe({ + next: () => + this.toast.success( + on ? `${issue.key} está en tu día` : `${issue.key} fuera de tu día`, + ), + error: (error) => { + this.inMyDay.set(!on); + this.toast.error( + (error as { message?: string })?.message?.split('\n')[0] || + 'No se pudo cambiar', + ); + }, + }); + } + protected openReminder(): void { const current = this.facade.issue()?.myReminder; this.reminderChoices.set(reminderChoices()); diff --git a/libs/web/tracker/presenters/src/lib/pages/projects.page.ts b/libs/web/tracker/presenters/src/lib/pages/projects.page.ts index 95662d3..da6ba60 100644 --- a/libs/web/tracker/presenters/src/lib/pages/projects.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/projects.page.ts @@ -54,6 +54,7 @@ import { WeekAgendaComponent } from '../ui/home/week-agenda'; import { HomeKpisComponent } from '../ui/home/kpis'; import { MyViewsComponent } from '../ui/my-views'; import { PinnedComponent } from '../ui/home/pinned'; +import { MyDayComponent } from '../ui/home/my-day'; /** How often the home page refreshes its reading while open. */ const REFRESH_MS = 60_000; @@ -77,6 +78,7 @@ const REFRESH_MS = 60_000; HomeKpisComponent, MyViewsComponent, PinnedComponent, + MyDayComponent, PiActionButtonComponent, PiEmptyStateComponent, PiSkeletonComponent, @@ -158,6 +160,7 @@ const REFRESH_MS = 60_000; @if (facade.projects().length) { + @if (myHome.home(); as h) { + issue.statusCategory === StatusCategory.DONE || + issue.statusCategory === StatusCategory.CANCELLED; + +/** + * «Mi día»: the few tickets I pick for today, crossed out as they get done, + * with my open ones to add and what was left from my last day. + */ +@Component({ + selector: 'ts-my-day', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterLink], + host: { + class: 'ts-panel', + role: 'region', + 'aria-labelledby': 'my-day-title', + }, + styleUrl: './home-panel.scss', + template: ` +
+

☀ Mi día

+ @if (picked().length) { + {{ done() }} de {{ picked().length }} terminados + } +
+ @if (myDay(); as day) { + @if (day.leftover; as left) { +

+ {{ + left.issues.length === 1 + ? 'Quedó 1 ticket sin terminar' + : 'Quedaron ' + left.issues.length + ' tickets sin terminar' + }} + {{ dayLabel(left.day) }}. + +

+ } + @if (picked().length) { + + @if (allDone()) { +

Todo hecho por hoy. 🎉

+ } + } @else { +

+ Elige lo que quieres sacar hoy: tres o cuatro tickets mejor que diez. +

+ } + @if (suggestions().length && picked().length < 10) { +

Tus tickets abiertos

+
    + @for (issue of suggestions(); track issue.id) { +
  • + +
  • + } +
+ } + } + `, + styles: ` + .ts-my-day__list li { + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + &.is-done .ts-title { + color: var(--ts-muted); + text-decoration: line-through; + } + } + .ts-my-day__check { + width: 1.25rem; + color: var(--ts-c-green); + font-weight: 700; + text-align: center; + } + .ts-key { + margin-right: 0.25rem; + } + .ts-my-day__leftover { + margin: 0 0 0.875rem; + padding: 0.5rem 0.75rem; + border-radius: 0.5rem; + background: color-mix(in srgb, var(--ts-c-amber) 14%, transparent); + font-size: 0.875rem; + } + .ts-my-day__subtitle { + margin: 1rem 0 0.5rem; + color: var(--ts-muted); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + } + .ts-my-day__suggestions { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin: 0; + padding: 0; + list-style: none; + } + /* Plain buttons, not @pi/ui's sized ones. */ + .ts-my-day__add, + .ts-my-day__remove, + .ts-my-day__link { + height: auto !important; + min-width: 0 !important; + font: inherit; + cursor: pointer; + } + .ts-my-day__add { + max-width: 20rem; + overflow: hidden; + padding: 0.25rem 0.75rem !important; + border: 1px dashed var(--ts-line-strong) !important; + border-radius: 999px !important; + background: transparent !important; + color: var(--ts-ink-soft); + font-size: 0.8125rem; + text-overflow: ellipsis; + white-space: nowrap; + &:hover, + &:focus-visible { + border-color: var(--ts-brand); + color: var(--ts-ink); + } + } + .ts-my-day__remove { + width: 1.75rem !important; + height: 1.75rem !important; + padding: 0 !important; + border: 0; + border-radius: 50%; + background: transparent; + color: var(--ts-muted); + font-size: 1.125rem; + &:hover, + &:focus-visible { + background: var(--ts-sunken); + color: var(--ts-ink); + } + } + .ts-my-day__link { + padding: 0 !important; + border: 0; + background: none !important; + color: var(--ts-brand); + font-weight: 600; + &:hover, + &:focus-visible { + text-decoration: underline; + } + } + `, +}) +export class MyDayComponent { + private readonly issues = inject(IssuesGateway); + private readonly toast = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); + private readonly day = localDay(new Date()); + protected readonly myDay = signal(null); + private readonly mine = signal([]); + + protected readonly picked = computed(() => this.myDay()?.issues ?? []); + protected readonly done = computed( + () => this.picked().filter(finished).length, + ); + protected readonly allDone = computed( + () => this.picked().length > 0 && this.done() === this.picked().length, + ); + /** My open tickets not picked yet (nor offered as left over). */ + protected readonly suggestions = computed(() => { + const taken = new Set([ + ...this.picked().map((i) => i.id), + ...(this.myDay()?.leftover?.issues ?? []).map((i) => i.id), + ]); + return this.mine() + .filter((i) => !taken.has(i.id)) + .slice(0, 6); + }); + protected readonly isDone = finished; + + constructor() { + this.reload(); + this.issues + .mine({ assignedToMe: true, categories: OPEN, limit: 20 }) + .pipe( + catchError(() => of({ items: [], total: 0 })), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((page) => this.mine.set(page.items)); + } + + protected projectOf(key: string): string { + return key.slice(0, key.lastIndexOf('-')); + } + + protected dayLabel(day: string): string { + const yesterday = localDay(new Date(Date.now() - 86_400_000)); + if (day === yesterday) return 'ayer'; + return ( + 'el ' + + new Date(`${day}T12:00:00`).toLocaleDateString('es', { + weekday: 'long', + day: 'numeric', + }) + ); + } + + protected add(issues: IssueSummary[]): void { + this.run( + this.issues.addToMyDay( + this.day, + issues.map((i) => i.id), + ), + 'No se pudo añadir', + ); + } + + protected carryOver(issues: IssueSummary[]): void { + this.add(issues); + } + + protected remove(issue: IssueSummary): void { + this.run( + this.issues.removeFromMyDay(this.day, issue.id), + 'No se pudo quitar', + ); + } + + private run(command$: Observable, failure: string): void { + command$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: () => this.reload(), + error: (error) => + this.toast.error( + (error as { message?: string })?.message?.split('\n')[0] || failure, + ), + }); + } + + private reload(): void { + this.issues + .myDay(this.day) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (myDay) => this.myDay.set(myDay), + error: () => + this.myDay.set({ day: this.day, issues: [], leftover: null }), + }); + } +}