diff --git a/apps/api/e2e/tracker.e2e-spec.ts b/apps/api/e2e/tracker.e2e-spec.ts index 23814e2..2fdf040 100644 --- a/apps/api/e2e/tracker.e2e-spec.ts +++ b/apps/api/e2e/tracker.e2e-spec.ts @@ -1425,6 +1425,26 @@ describe('task-stream (e2e)', () => { expect(insights.body['journey']).toMatchObject({ samples: 0, steps: [] }); expect(insights.body['proposals']).toBeNull(); + // A day of it: only what happened between after and before. + const tomorrow = new Date(Date.parse(`${today}T00:00:00Z`) + 86_400_000); + const ofToday = await rest( + 'token-ana', + 'GET', + `/projects/${key}/activity?after=${today}T00:00:00.000Z&before=${tomorrow.toISOString()}&limit=200`, + ); + expect(ofToday.body.length).toBeGreaterThan(0); + expect( + ofToday.body.every((a: { occurredAt: string }) => + a.occurredAt.startsWith(today), + ), + ).toBe(true); + const ofYesterday = await rest( + 'token-ana', + 'GET', + `/projects/${key}/activity?after=2020-01-01T00:00:00.000Z&before=2020-01-02T00:00:00.000Z`, + ); + expect(ofYesterday.body).toEqual([]); + // What changed since a moment, and what the backlog lacks. const since = new Date(Date.now() - 60_000).toISOString(); const later = await rest( diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 5783ffe..297b9d6 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -1775,6 +1775,16 @@ "format": "date-time", "type": "string" } + }, + { + "name": "after", + "required": false, + "in": "query", + "description": "Entries at or after this date (with before, a window such as a day)", + "schema": { + "format": "date-time", + "type": "string" + } } ], "responses": { diff --git a/apps/api/schema.gql b/apps/api/schema.gql index 14af85d..f8548ee 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -1464,6 +1464,11 @@ type Quality { type Query { """Project activity feed (newest first)""" activity( + """ + ISO date: only activity at or after it (with before, a window such as a day) + """ + after: String + """ISO date cursor""" before: String issueId: ID diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 85691c7..11402d1 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -4750,6 +4750,8 @@ export interface operations { limit?: number; /** @description Cursor: entries older than this date */ before?: string; + /** @description Entries at or after this date (with before, a window such as a day) */ + after?: string; }; header?: never; path: { diff --git a/apps/mcp/src/app/tools/projects.resolver.ts b/apps/mcp/src/app/tools/projects.resolver.ts index 97f0ffb..7e4dac5 100644 --- a/apps/mcp/src/app/tools/projects.resolver.ts +++ b/apps/mcp/src/app/tools/projects.resolver.ts @@ -280,19 +280,36 @@ export class ProjectsResolver { @Tool({ name: 'get_project_activity', - description: 'Latest activity of a project (newest first).', - paramsSchema: z.object({ project: projectRef, limit: limit(200, 30) }), + description: + 'Latest activity of a project (newest first), or that of one day (UTC) with day.', + paramsSchema: z.object({ + project: projectRef, + limit: limit(200, 30), + day: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional() + .describe('YYYY-MM-DD: only what happened that day (UTC)'), + }), annotations: READ, }) activity( - { project, limit }: { project: string; limit: number }, + { project, limit, day }: { project: string; limit: number; day?: string }, ctx: McpContext, ) { + const window = day + ? { + after: `${day}T00:00:00.000Z`, + before: new Date( + Date.parse(`${day}T00:00:00Z`) + 86_400_000, + ).toISOString(), + } + : {}; return toolResult(async () => ( await this.api.data( this.api.as(ctx).GET('/api/projects/{project}/activity', { - params: { path: { project }, query: { limit } }, + params: { path: { project }, query: { limit, ...window } }, }), ) ).map((a) => ({ diff --git a/docs/03-api.md b/docs/03-api.md index 3ef5e5a..4d91211 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -501,7 +501,7 @@ que funcionan también sin token. | `epics(projectId)` | Épicas del proyecto (tickets de primer nivel de los que cuelgan otros) con el progreso de sus hijos | | `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` | | `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` | -| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo | +| `activity(projectId, issueId?, limit, before?, after?)` | Feed de actividad, del más reciente al más antiguo; con `after` y `before`, una ventana (un día, por ejemplo). Por REST, los mismos parámetros en `…/activity` | ### Mutations diff --git a/docs/04-cliente-web.md b/docs/04-cliente-web.md index 8a684a2..be9bb29 100644 --- a/docs/04-cliente-web.md +++ b/docs/04-cliente-web.md @@ -297,25 +297,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), 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) | -| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno) | -| `/p/:key/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, 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, 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), 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) | +| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno) | +| `/p/:key/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, 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 12fbb06..aba488a 100644 --- a/docs/09-mcp.md +++ b/docs/09-mcp.md @@ -51,7 +51,7 @@ flowchart LR | | `get_portfolio` | Todos mis proyectos activos de un vistazo: abiertos y en riesgo, sprint, previsión de releases y épicas | | | `get_my_week` | Mi semana: lo mío que vence en 7 días, fin de sprints y releases; lo terminado por mí y por mis equipos, racha; sprints en curso y qué podría coger | | | `get_project_insights` | El panel: sprint, riesgo, antigüedad, cuellos de botella, releases, equipo, backlog, calidad, campos, actividad por día, recorrido de un ticket, propuestas y, con `since`, los cambios | -| | `get_project_activity` | Actividad reciente | +| | `get_project_activity` | Actividad reciente, o la de un día (`day`) | | Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo); también por prioridad, solo bugs o vencidos | | | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` | | | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios | diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index 3b7d0d6..81f02ca 100644 Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf index f56b934..a25a0bc 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ diff --git a/libs/api/tracker/application/src/lib/issues.service.ts b/libs/api/tracker/application/src/lib/issues.service.ts index ed414b7..c1a5b15 100644 --- a/libs/api/tracker/application/src/lib/issues.service.ts +++ b/libs/api/tracker/application/src/lib/issues.service.ts @@ -432,6 +432,7 @@ export class IssuesService { issueId?: string; limit?: number; before?: string; + after?: string; }, ): Promise { return this.queryBus.execute( @@ -441,6 +442,7 @@ export class IssuesService { params.issueId, params.limit, params.before, + params.after, ), ); } diff --git a/libs/api/tracker/application/src/lib/ports/find-activity.repository.ts b/libs/api/tracker/application/src/lib/ports/find-activity.repository.ts index 2b08588..79bbf67 100644 --- a/libs/api/tracker/application/src/lib/ports/find-activity.repository.ts +++ b/libs/api/tracker/application/src/lib/ports/find-activity.repository.ts @@ -7,6 +7,8 @@ export abstract class FindActivityRepository { limit: number; /** ISO date cursor: only activity strictly older than this. */ before?: string; + /** ISO date: only activity at or after this. */ + after?: string; }): Promise; /** The latest of several projects, newest first. */ abstract findAcross( diff --git a/libs/api/tracker/application/src/lib/queries/get-activity.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-activity.query-handler.ts index 7e211ae..133688b 100644 --- a/libs/api/tracker/application/src/lib/queries/get-activity.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/get-activity.query-handler.ts @@ -35,6 +35,7 @@ export class GetActivityQueryHandler issueId: query.issueId, limit: Math.min(query.limit, 200), before: query.before, + after: query.after, }); } } diff --git a/libs/api/tracker/application/src/lib/queries/get-activity.query.ts b/libs/api/tracker/application/src/lib/queries/get-activity.query.ts index ad97cbb..5d3f510 100644 --- a/libs/api/tracker/application/src/lib/queries/get-activity.query.ts +++ b/libs/api/tracker/application/src/lib/queries/get-activity.query.ts @@ -10,6 +10,8 @@ export class GetActivityQuery extends Query { public readonly limit = 50, /** ISO date cursor: only activity strictly older than this. */ public readonly before?: string, + /** ISO date: only activity at or after this (with `before`, a window). */ + public readonly after?: string, ) { super(); } diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts index 4f34c7c..5ccb3bf 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts @@ -603,10 +603,15 @@ export class MongoFindActivityRepository extends FindActivityRepository { issueId?: string; limit: number; before?: string; + after?: string; }): Promise { const query: FilterQuery = { projectId: params.projectId }; if (params.issueId) query.issueId = params.issueId; - if (params.before) query.occurredAt = { $lt: params.before }; + if (params.before || params.after) + query.occurredAt = { + ...(params.before && { $lt: params.before }), + ...(params.after && { $gte: params.after }), + }; const docs = await this.activity .find(query) .sort({ occurredAt: -1 }) 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 d6c4785..9d2eadb 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts @@ -193,12 +193,19 @@ export class IssueResolver { @Args('limit', { type: () => Int, defaultValue: 50 }) limit = 50, @Args('before', { nullable: true, description: 'ISO date cursor' }) before?: string, + @Args('after', { + nullable: true, + description: + 'ISO date: only activity at or after it (with before, a window such as a day)', + }) + after?: string, ) { return this.issuesService.activity(viewerOf(me), { projectId, issueId, limit, before, + after, }); } 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 c9ef52b..94809e6 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 @@ -473,6 +473,15 @@ export class ActivityQueryDto { @IsOptional() @IsISO8601() before?: string; + + @ApiPropertyOptional({ + format: 'date-time', + description: + 'Entries at or after this date (with before, a window such as a day)', + }) + @IsOptional() + @IsISO8601() + after?: string; } export class PageQueryDto { diff --git a/libs/api/tracker/presenters/src/lib/http/projects.controller.ts b/libs/api/tracker/presenters/src/lib/http/projects.controller.ts index 447605c..e055cce 100644 --- a/libs/api/tracker/presenters/src/lib/http/projects.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/projects.controller.ts @@ -316,6 +316,7 @@ export class ProjectsController { issueId: query.issueId, limit: query.limit, before: query.before, + after: query.after, }); } diff --git a/libs/web/tracker/application/src/lib/facades/insights.facade.ts b/libs/web/tracker/application/src/lib/facades/insights.facade.ts index 47c308c..b07d3ea 100644 --- a/libs/web/tracker/application/src/lib/facades/insights.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/insights.facade.ts @@ -1,5 +1,7 @@ -import { inject, Injectable, Provider } from '@angular/core'; +import { inject, Injectable, Provider, signal } from '@angular/core'; import { injectDispatch } from '@ngrx/signals/events'; +import { Activity, ActivityGateway } from '@task-stream/web/tracker/domain'; +import { describeError } from '../notifications'; import { insightsPageEvents } from '../events/insights.events'; import { InsightsStore } from '../stores/insights.store'; @@ -8,6 +10,7 @@ import { InsightsStore } from '../stores/insights.store'; export class InsightsFacade { private readonly store = inject(InsightsStore); private readonly dispatch = injectDispatch(insightsPageEvents); + private readonly activity = inject(ActivityGateway); readonly insights = this.store.insights; /** False until the first load: show skeletons, not empty states. */ @@ -21,6 +24,28 @@ export class InsightsFacade { open(projectId: string, since: string | null = null): void { this.dispatch.opened({ projectId, since }); } + + /** The day picked on the activity map: what happened then. */ + readonly day = signal<{ + day: string; + entries: Activity[] | null; + error: string | null; + } | null>(null); + + /** Reads what happened that day (`YYYY-MM-DD`) into `day`. */ + openDay(projectId: string, day: string): void { + this.day.set({ day, entries: null, error: null }); + this.activity.onDay(projectId, day).subscribe({ + next: (entries) => { + if (this.day()?.day === day) + this.day.set({ day, entries, error: null }); + }, + error: (error) => { + if (this.day()?.day === day) + this.day.set({ day, entries: [], error: describeError(error) }); + }, + }); + } } /** Store + facade of the dashboard, for its route component. */ diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index 610465c..f0c8dc9 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -358,6 +358,8 @@ export abstract class SprintsGateway { export abstract class ActivityGateway { abstract list(projectId: string, issueId?: string): Observable; + /** What happened that day (`YYYY-MM-DD`, UTC), newest first (200 at most). */ + abstract onDay(projectId: string, day: string): Observable; } export abstract class UsersGateway { diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-collaboration.gateways.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-collaboration.gateways.ts index 8d1cef6..017fdd1 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-collaboration.gateways.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-collaboration.gateways.ts @@ -15,6 +15,7 @@ import { import { map, Observable } from 'rxjs'; import { ActivityDocument, + ActivityOfDayDocument, AddCommentDocument, ArchiveReleaseDocument, CreateReleaseDocument, @@ -144,6 +145,14 @@ export class GraphqlActivityGateway extends ActivityGateway { .query(ActivityDocument, { projectId, issueId: issueId ?? null }) .pipe(map((d) => d.activity.map(toActivity))); } + + onDay(projectId: string, day: string): Observable { + const after = `${day}T00:00:00.000Z`; + const before = new Date(Date.parse(after) + 86_400_000).toISOString(); + return this.gql + .query(ActivityOfDayDocument, { projectId, after, before }) + .pipe(map((d) => d.activity.map(toActivity))); + } } @Injectable() diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index d2c5054..c83ba67 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -2358,6 +2358,25 @@ export type WriteReleaseNotesMutation = { writeReleaseNotes: { id: string; key: string | null; version: number }; }; +export type ActivityOfDayQueryVariables = Exact<{ + projectId: string; + after: string; + before: string; +}>; + +export type ActivityOfDayQuery = { + activity: Array<{ + id: string; + type: string; + projectId: string; + issueId: string | null; + issueKey: string | null; + summary: string; + occurredAt: string; + actor: { id: string; name: string }; + }>; +}; + export type SavedFilterFieldsFragment = { id: string; projectId: string; @@ -14144,6 +14163,154 @@ export const WriteReleaseNotesDocument = { WriteReleaseNotesMutation, WriteReleaseNotesMutationVariables >; +export const ActivityOfDayDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'ActivityOfDay' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'projectId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'after' }, + }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'String' }, + }, + }, + }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'before' }, + }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'String' }, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'activity' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'projectId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'projectId' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'after' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'after' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'before' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'before' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'limit' }, + value: { kind: 'IntValue', value: '200' }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'ActivityFields' }, + }, + ], + }, + }, + ], + }, + }, + { + 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: 'ActivityFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Activity' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'type' } }, + { kind: 'Field', name: { kind: 'Name', value: 'projectId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'issueId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'issueKey' } }, + { kind: 'Field', name: { kind: 'Name', value: 'summary' } }, + { kind: 'Field', name: { kind: 'Name', value: 'occurredAt' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'actor' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode; export const SavedFiltersDocument = { kind: 'Document', definitions: [ diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/releases.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/releases.graphql index af53431..6aa8bc8 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/releases.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/releases.graphql @@ -72,3 +72,9 @@ mutation WriteReleaseNotes($releaseId: ID!, $notes: String) { ...CommandResultFields } } + +query ActivityOfDay($projectId: ID!, $after: String!, $before: String!) { + activity(projectId: $projectId, after: $after, before: $before, limit: 200) { + ...ActivityFields + } +} diff --git a/libs/web/tracker/presenters/src/lib/pages/dashboard.page.ts b/libs/web/tracker/presenters/src/lib/pages/dashboard.page.ts index fbcbd7f..adcd320 100644 --- a/libs/web/tracker/presenters/src/lib/pages/dashboard.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/dashboard.page.ts @@ -10,8 +10,11 @@ import { HostListener, inject, signal, + TemplateRef, untracked, + viewChild, } from '@angular/core'; +import { DrawerService } from '@task-stream/web/shared/ui'; import { RouterLink } from '@angular/router'; import { PiNoteComponent, @@ -24,6 +27,7 @@ import { provideInsightsFacade, } from '@task-stream/web/tracker/application'; import { + Activity, ProjectRole, QA_FAILED, StatusCategory, @@ -72,6 +76,57 @@ const SLIDE_SECONDS = 15; providers: [provideInsightsFacade()], host: { '[class.is-screen]': 'screen()' }, template: ` + + @if (insights.day(); as d) { + @if (!d.entries) { + + } @else if (d.error) { + + } @else { +

+ {{ d.entries.length }} + {{ d.entries.length === 1 ? 'movimiento' : 'movimientos' }} + @if (d.entries.length >= 200) { + (los 200 últimos) + } + · {{ dayTickets().length }} + {{ dayTickets().length === 1 ? 'ticket' : 'tickets' }} +

+ @for (ticket of dayTickets(); track ticket.key) { +
+ {{ ticket.key }} +
    + @for (entry of ticket.entries; track entry.id) { +
  • + + {{ entry.summary }} +
  • + } +
+
+ } + @if (dayOthers().length) { +
+

Del proyecto

+
    + @for (entry of dayOthers(); track entry.id) { +
  • + + {{ entry.summary }} +
  • + } +
+
+ } + } + } +
+ @if (screen()) {
@@ -192,7 +247,11 @@ const SLIDE_SECONDS = 15; [journey]="reading.journey" [workflow]="facade.workflow()" /> - + >(ElementRef); + protected readonly drawer = inject(DrawerService); + private readonly dayPanel = + viewChild.required>('dayPanel'); + + /** A day of the activity map, in a side panel: its tickets and moves. */ + protected openDay(day: string): void { + const projectId = this.facade.project()?.id; + if (!projectId) return; + this.insights.openDay(projectId, day); + const label = new Date(`${day}T00:00:00Z`).toLocaleDateString('es', { + weekday: 'long', + day: 'numeric', + month: 'long', + timeZone: 'UTC', + }); + this.drawer.open(`Actividad del ${label}`, this.dayPanel()); + } + + /** That day's moves, ticket by ticket (most active first). */ + protected readonly dayTickets = computed(() => { + const groups = new Map(); + for (const entry of this.insights.day()?.entries ?? []) + if (entry.issueKey) + groups.set(entry.issueKey, [ + ...(groups.get(entry.issueKey) ?? []), + entry, + ]); + return [...groups.entries()] + .map(([key, entries]) => ({ key, entries })) + .sort((a, b) => b.entries.length - a.entries.length); + }); + /** The moves that are not about one ticket (sprints, releases, members). */ + protected readonly dayOthers = computed(() => + (this.insights.day()?.entries ?? []).filter((e) => !e.issueKey), + ); + + protected time(iso: string): string { + return new Date(iso).toLocaleTimeString('es', { + hour: '2-digit', + minute: '2-digit', + }); + } private readonly document = inject(DOCUMENT); /* --- Modo pantalla: for a team's TV, a block at a time, rotating --- */ diff --git a/libs/web/tracker/presenters/src/lib/ui/insights/activity-heatmap.ts b/libs/web/tracker/presenters/src/lib/ui/insights/activity-heatmap.ts index f2deeb5..bfefea1 100644 --- a/libs/web/tracker/presenters/src/lib/ui/insights/activity-heatmap.ts +++ b/libs/web/tracker/presenters/src/lib/ui/insights/activity-heatmap.ts @@ -3,6 +3,7 @@ import { Component, computed, input, + output, } from '@angular/core'; const WEEKDAYS = [ @@ -55,12 +56,23 @@ const MONTHS = [ }} @for (week of weeks(); track week.start) { @if (week.days[row]; as day) { - + @if (selectable() && day.count && !day.future) { + + } @else { + + } } } } @@ -116,6 +128,21 @@ const MONTHS = [ outline: 1px dashed var(--ts-line); outline-offset: -1px; } + /* A plain square, past @pi/ui's global button look. */ + &.is-selectable { + display: block; + width: 100% !important; + min-width: 0 !important; + height: auto !important; + padding: 0 !important; + border: 0; + cursor: pointer; + &:hover, + &:focus-visible { + outline: 2px solid var(--ts-ink); + outline-offset: 1px; + } + } } .ts-heat__note { margin-top: 0.875rem; @@ -127,6 +154,10 @@ export class ActivityHeatmapComponent { readonly days = input.required<{ day: string; count: number }[]>(); /** Today (`YYYY-MM-DD`), to grey out the rest of this week. */ readonly today = input(new Date().toISOString().slice(0, 10)); + /** Days with activity can be picked (not on the page for clients). */ + readonly selectable = input(false); + /** A day picked, as `YYYY-MM-DD`. */ + readonly daySelected = output(); protected readonly total = computed(() => this.days().reduce((sum, d) => sum + d.count, 0), @@ -139,7 +170,13 @@ export class ActivityHeatmapComponent { const weeks: { start: string; month: string; - days: { level: number; future: boolean; title: string }[]; + days: { + date: string; + count: number; + level: number; + future: boolean; + title: string; + }[]; }[] = []; for (let w = 0; w * 7 < days.length; w++) { const week = days.slice(w * 7, w * 7 + 7); @@ -162,6 +199,8 @@ export class ActivityHeatmapComponent { days: week.map((d) => { const future = d.day > this.today(); return { + date: d.day, + count: d.count, future, level: d.count ? Math.min(4, Math.ceil((d.count / max) * 4)) : 0, title: future