Compare commits

..

2 Commits

Author SHA1 Message Date
d66b1f3ece feat: a day of the activity map opens what happened, ticket by ticket
On the project dashboard, a day with activity can be picked on the map:
a side panel lists that day's moves grouped by ticket (each key opens
it), with the time of each, and the project's own (sprints, releases,
members) apart.

The activity feed takes an `after` date (GraphQL, REST), so with
`before` it reads a window such as a day; the MCP's
get_project_activity takes a `day`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:42:30 +02:00
de9c8a7780 fix(web): «Desde tu última visita» keeps its four numbers in line
Each number sat under its label, and in a narrow panel «Vueltos de QA»
takes two lines, so its number fell lower than the others. The numbers
go on top now (the markup stays label then value).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:42:27 +02:00
26 changed files with 521 additions and 35 deletions

View File

@@ -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(

View File

@@ -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": {

View File

@@ -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

View File

@@ -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: {

View File

@@ -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) => ({

View File

@@ -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

View File

@@ -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

View File

@@ -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 |

Binary file not shown.

Binary file not shown.

View File

@@ -432,6 +432,7 @@ export class IssuesService {
issueId?: string;
limit?: number;
before?: string;
after?: string;
},
): Promise<ActivityReadModel[]> {
return this.queryBus.execute(
@@ -441,6 +442,7 @@ export class IssuesService {
params.issueId,
params.limit,
params.before,
params.after,
),
);
}

View File

@@ -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<ActivityReadModel[]>;
/** The latest of several projects, newest first. */
abstract findAcross(

View File

@@ -35,6 +35,7 @@ export class GetActivityQueryHandler
issueId: query.issueId,
limit: Math.min(query.limit, 200),
before: query.before,
after: query.after,
});
}
}

View File

@@ -10,6 +10,8 @@ export class GetActivityQuery extends Query<ActivityReadModel[]> {
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();
}

View File

@@ -603,10 +603,15 @@ export class MongoFindActivityRepository extends FindActivityRepository {
issueId?: string;
limit: number;
before?: string;
after?: string;
}): Promise<ActivityReadModel[]> {
const query: FilterQuery<ActivityDoc> = { 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 })

View File

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

View File

@@ -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 {

View File

@@ -316,6 +316,7 @@ export class ProjectsController {
issueId: query.issueId,
limit: query.limit,
before: query.before,
after: query.after,
});
}

View File

@@ -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. */

View File

@@ -358,6 +358,8 @@ export abstract class SprintsGateway {
export abstract class ActivityGateway {
abstract list(projectId: string, issueId?: string): Observable<Activity[]>;
/** What happened that day (`YYYY-MM-DD`, UTC), newest first (200 at most). */
abstract onDay(projectId: string, day: string): Observable<Activity[]>;
}
export abstract class UsersGateway {

View File

@@ -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<Activity[]> {
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()

View File

@@ -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<ActivityOfDayQuery, ActivityOfDayQueryVariables>;
export const SavedFiltersDocument = {
kind: 'Document',
definitions: [

View File

@@ -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
}
}

View File

@@ -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: `
<ng-template #dayPanel>
@if (insights.day(); as d) {
@if (!d.entries) {
<pi-skeleton shape="block" height="8rem" />
} @else if (d.error) {
<p class="ts-day__error" role="alert">{{ d.error }}</p>
} @else {
<p class="ts-muted ts-day__summary">
{{ d.entries.length }}
{{ d.entries.length === 1 ? 'movimiento' : 'movimientos' }}
@if (d.entries.length >= 200) {
(los 200 últimos)
}
· {{ dayTickets().length }}
{{ dayTickets().length === 1 ? 'ticket' : 'tickets' }}
</p>
@for (ticket of dayTickets(); track ticket.key) {
<section class="ts-day__ticket">
<a
class="ts-key"
[routerLink]="['../issues', ticket.key]"
(click)="drawer.closeAll()"
>{{ ticket.key }}</a
>
<ul>
@for (entry of ticket.entries; track entry.id) {
<li>
<time class="ts-mono">{{ time(entry.occurredAt) }}</time>
{{ entry.summary }}
</li>
}
</ul>
</section>
}
@if (dayOthers().length) {
<section class="ts-day__ticket">
<h3>Del proyecto</h3>
<ul>
@for (entry of dayOthers(); track entry.id) {
<li>
<time class="ts-mono">{{ time(entry.occurredAt) }}</time>
{{ entry.summary }}
</li>
}
</ul>
</section>
}
}
}
</ng-template>
@if (screen()) {
<header class="ts-screen-bar">
<span class="ts-screen-bar__project">
@@ -192,7 +247,11 @@ const SLIDE_SECONDS = 15;
[journey]="reading.journey"
[workflow]="facade.workflow()"
/>
<ts-activity-heatmap [days]="reading.heatmap" />
<ts-activity-heatmap
[days]="reading.heatmap"
[selectable]="true"
(daySelected)="openDay($event)"
/>
<ts-backlog-health [health]="reading.backlog" />
<ts-quality
[quality]="reading.quality"
@@ -312,6 +371,51 @@ const SLIDE_SECONDS = 15;
}
`,
styles: `
.ts-day__summary {
margin: 0 0 1rem;
font-size: 0.875rem;
}
.ts-day__error {
color: var(--ts-c-red);
}
.ts-day__ticket {
display: grid;
gap: 0.375rem;
padding: 0.75rem 0;
border-top: 1px solid var(--ts-line);
h3 {
margin: 0;
color: var(--ts-muted);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.ts-key {
justify-self: start;
font-family: var(--ts-font-mono);
font-size: 0.8125rem;
font-weight: 600;
color: var(--ts-brand);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
ul {
display: grid;
gap: 0.25rem;
margin: 0;
padding: 0;
list-style: none;
font-size: 0.875rem;
}
time {
margin-right: 0.5rem;
color: var(--ts-muted);
font-size: 0.75rem;
}
}
.ts-screen-open {
display: flex;
justify-content: flex-end;
@@ -614,6 +718,48 @@ export class DashboardPage {
protected readonly facade = inject(ProjectFacade);
protected readonly insights = inject(InsightsFacade);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
protected readonly drawer = inject(DrawerService);
private readonly dayPanel =
viewChild.required<TemplateRef<unknown>>('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<string, Activity[]>();
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 --- */

View File

@@ -3,6 +3,7 @@ import {
Component,
computed,
input,
output,
} from '@angular/core';
const WEEKDAYS = [
@@ -55,12 +56,23 @@ const MONTHS = [
}}</span>
@for (week of weeks(); track week.start) {
@if (week.days[row]; as day) {
<span
class="ts-heat__day"
[attr.data-level]="day.level"
[class.is-future]="day.future"
[attr.title]="day.title"
></span>
@if (selectable() && day.count && !day.future) {
<button
type="button"
class="ts-heat__day is-selectable"
[attr.data-level]="day.level"
[attr.title]="day.title + ' · ver qué pasó'"
[attr.aria-label]="day.title + '. Ver qué pasó ese día'"
(click)="daySelected.emit(day.date)"
></button>
} @else {
<span
class="ts-heat__day"
[attr.data-level]="day.level"
[class.is-future]="day.future"
[attr.title]="day.title"
></span>
}
}
}
}
@@ -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<string>(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<string>();
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

View File

@@ -60,15 +60,24 @@ import { timeAgo } from '../copy';
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.5rem;
margin: 0 0 0.75rem;
/* The number on top: all four in line even when a label («Vueltos de
QA») takes two lines in a narrow panel. */
> div {
display: flex;
flex-direction: column;
}
dt {
color: var(--ts-muted);
font-size: 0.75rem;
line-height: 1.3;
}
dd {
order: -1;
margin: 0;
font-family: var(--ts-font-mono);
font-size: 1.25rem;
font-weight: 600;
line-height: 1.3;
}
}
h3 {