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>
This commit is contained in:
@@ -1425,6 +1425,26 @@ describe('task-stream (e2e)', () => {
|
|||||||
expect(insights.body['journey']).toMatchObject({ samples: 0, steps: [] });
|
expect(insights.body['journey']).toMatchObject({ samples: 0, steps: [] });
|
||||||
expect(insights.body['proposals']).toBeNull();
|
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.
|
// What changed since a moment, and what the backlog lacks.
|
||||||
const since = new Date(Date.now() - 60_000).toISOString();
|
const since = new Date(Date.now() - 60_000).toISOString();
|
||||||
const later = await rest(
|
const later = await rest(
|
||||||
|
|||||||
@@ -1775,6 +1775,16 @@
|
|||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"type": "string"
|
"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": {
|
"responses": {
|
||||||
|
|||||||
@@ -1464,6 +1464,11 @@ type Quality {
|
|||||||
type Query {
|
type Query {
|
||||||
"""Project activity feed (newest first)"""
|
"""Project activity feed (newest first)"""
|
||||||
activity(
|
activity(
|
||||||
|
"""
|
||||||
|
ISO date: only activity at or after it (with before, a window such as a day)
|
||||||
|
"""
|
||||||
|
after: String
|
||||||
|
|
||||||
"""ISO date cursor"""
|
"""ISO date cursor"""
|
||||||
before: String
|
before: String
|
||||||
issueId: ID
|
issueId: ID
|
||||||
|
|||||||
@@ -4750,6 +4750,8 @@ export interface operations {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
/** @description Cursor: entries older than this date */
|
/** @description Cursor: entries older than this date */
|
||||||
before?: string;
|
before?: string;
|
||||||
|
/** @description Entries at or after this date (with before, a window such as a day) */
|
||||||
|
after?: string;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path: {
|
path: {
|
||||||
|
|||||||
@@ -280,19 +280,36 @@ export class ProjectsResolver {
|
|||||||
|
|
||||||
@Tool({
|
@Tool({
|
||||||
name: 'get_project_activity',
|
name: 'get_project_activity',
|
||||||
description: 'Latest activity of a project (newest first).',
|
description:
|
||||||
paramsSchema: z.object({ project: projectRef, limit: limit(200, 30) }),
|
'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,
|
annotations: READ,
|
||||||
})
|
})
|
||||||
activity(
|
activity(
|
||||||
{ project, limit }: { project: string; limit: number },
|
{ project, limit, day }: { project: string; limit: number; day?: string },
|
||||||
ctx: McpContext,
|
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 () =>
|
return toolResult(async () =>
|
||||||
(
|
(
|
||||||
await this.api.data(
|
await this.api.data(
|
||||||
this.api.as(ctx).GET('/api/projects/{project}/activity', {
|
this.api.as(ctx).GET('/api/projects/{project}/activity', {
|
||||||
params: { path: { project }, query: { limit } },
|
params: { path: { project }, query: { limit, ...window } },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
).map((a) => ({
|
).map((a) => ({
|
||||||
|
|||||||
@@ -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 |
|
| `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` |
|
| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
|
||||||
| `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` |
|
| `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
|
### Mutations
|
||||||
|
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ visualización de la plataforma:
|
|||||||
## 4.6 Rutas
|
## 4.6 Rutas
|
||||||
|
|
||||||
| Ruta | Página |
|
| Ruta | Página |
|
||||||
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `/login` | Inicio de sesión (solo sin sesión) |
|
| `/login` | Inicio de sesión (solo sin sesión) |
|
||||||
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
||||||
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» |
|
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), 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» |
|
||||||
@@ -309,7 +309,7 @@ visualización de la plataforma:
|
|||||||
| `/p/:key/releases` | Releases |
|
| `/p/:key/releases` | Releases |
|
||||||
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
|
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
|
||||||
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
|
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
|
||||||
| `/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/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/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/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/activity` | Actividad en vivo |
|
||||||
|
|||||||
@@ -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_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_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_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 |
|
| 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` |
|
| | `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 |
|
| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios |
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -432,6 +432,7 @@ export class IssuesService {
|
|||||||
issueId?: string;
|
issueId?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
before?: string;
|
before?: string;
|
||||||
|
after?: string;
|
||||||
},
|
},
|
||||||
): Promise<ActivityReadModel[]> {
|
): Promise<ActivityReadModel[]> {
|
||||||
return this.queryBus.execute(
|
return this.queryBus.execute(
|
||||||
@@ -441,6 +442,7 @@ export class IssuesService {
|
|||||||
params.issueId,
|
params.issueId,
|
||||||
params.limit,
|
params.limit,
|
||||||
params.before,
|
params.before,
|
||||||
|
params.after,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export abstract class FindActivityRepository {
|
|||||||
limit: number;
|
limit: number;
|
||||||
/** ISO date cursor: only activity strictly older than this. */
|
/** ISO date cursor: only activity strictly older than this. */
|
||||||
before?: string;
|
before?: string;
|
||||||
|
/** ISO date: only activity at or after this. */
|
||||||
|
after?: string;
|
||||||
}): Promise<ActivityReadModel[]>;
|
}): Promise<ActivityReadModel[]>;
|
||||||
/** The latest of several projects, newest first. */
|
/** The latest of several projects, newest first. */
|
||||||
abstract findAcross(
|
abstract findAcross(
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export class GetActivityQueryHandler
|
|||||||
issueId: query.issueId,
|
issueId: query.issueId,
|
||||||
limit: Math.min(query.limit, 200),
|
limit: Math.min(query.limit, 200),
|
||||||
before: query.before,
|
before: query.before,
|
||||||
|
after: query.after,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export class GetActivityQuery extends Query<ActivityReadModel[]> {
|
|||||||
public readonly limit = 50,
|
public readonly limit = 50,
|
||||||
/** ISO date cursor: only activity strictly older than this. */
|
/** ISO date cursor: only activity strictly older than this. */
|
||||||
public readonly before?: string,
|
public readonly before?: string,
|
||||||
|
/** ISO date: only activity at or after this (with `before`, a window). */
|
||||||
|
public readonly after?: string,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -603,10 +603,15 @@ export class MongoFindActivityRepository extends FindActivityRepository {
|
|||||||
issueId?: string;
|
issueId?: string;
|
||||||
limit: number;
|
limit: number;
|
||||||
before?: string;
|
before?: string;
|
||||||
|
after?: string;
|
||||||
}): Promise<ActivityReadModel[]> {
|
}): Promise<ActivityReadModel[]> {
|
||||||
const query: FilterQuery<ActivityDoc> = { projectId: params.projectId };
|
const query: FilterQuery<ActivityDoc> = { projectId: params.projectId };
|
||||||
if (params.issueId) query.issueId = params.issueId;
|
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
|
const docs = await this.activity
|
||||||
.find(query)
|
.find(query)
|
||||||
.sort({ occurredAt: -1 })
|
.sort({ occurredAt: -1 })
|
||||||
|
|||||||
@@ -193,12 +193,19 @@ export class IssueResolver {
|
|||||||
@Args('limit', { type: () => Int, defaultValue: 50 }) limit = 50,
|
@Args('limit', { type: () => Int, defaultValue: 50 }) limit = 50,
|
||||||
@Args('before', { nullable: true, description: 'ISO date cursor' })
|
@Args('before', { nullable: true, description: 'ISO date cursor' })
|
||||||
before?: string,
|
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), {
|
return this.issuesService.activity(viewerOf(me), {
|
||||||
projectId,
|
projectId,
|
||||||
issueId,
|
issueId,
|
||||||
limit,
|
limit,
|
||||||
before,
|
before,
|
||||||
|
after,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -473,6 +473,15 @@ export class ActivityQueryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
before?: string;
|
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 {
|
export class PageQueryDto {
|
||||||
|
|||||||
@@ -316,6 +316,7 @@ export class ProjectsController {
|
|||||||
issueId: query.issueId,
|
issueId: query.issueId,
|
||||||
limit: query.limit,
|
limit: query.limit,
|
||||||
before: query.before,
|
before: query.before,
|
||||||
|
after: query.after,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { 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 { insightsPageEvents } from '../events/insights.events';
|
||||||
import { InsightsStore } from '../stores/insights.store';
|
import { InsightsStore } from '../stores/insights.store';
|
||||||
|
|
||||||
@@ -8,6 +10,7 @@ import { InsightsStore } from '../stores/insights.store';
|
|||||||
export class InsightsFacade {
|
export class InsightsFacade {
|
||||||
private readonly store = inject(InsightsStore);
|
private readonly store = inject(InsightsStore);
|
||||||
private readonly dispatch = injectDispatch(insightsPageEvents);
|
private readonly dispatch = injectDispatch(insightsPageEvents);
|
||||||
|
private readonly activity = inject(ActivityGateway);
|
||||||
|
|
||||||
readonly insights = this.store.insights;
|
readonly insights = this.store.insights;
|
||||||
/** False until the first load: show skeletons, not empty states. */
|
/** 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 {
|
open(projectId: string, since: string | null = null): void {
|
||||||
this.dispatch.opened({ projectId, since });
|
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. */
|
/** Store + facade of the dashboard, for its route component. */
|
||||||
|
|||||||
@@ -358,6 +358,8 @@ export abstract class SprintsGateway {
|
|||||||
|
|
||||||
export abstract class ActivityGateway {
|
export abstract class ActivityGateway {
|
||||||
abstract list(projectId: string, issueId?: string): Observable<Activity[]>;
|
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 {
|
export abstract class UsersGateway {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { map, Observable } from 'rxjs';
|
import { map, Observable } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
ActivityDocument,
|
ActivityDocument,
|
||||||
|
ActivityOfDayDocument,
|
||||||
AddCommentDocument,
|
AddCommentDocument,
|
||||||
ArchiveReleaseDocument,
|
ArchiveReleaseDocument,
|
||||||
CreateReleaseDocument,
|
CreateReleaseDocument,
|
||||||
@@ -144,6 +145,14 @@ export class GraphqlActivityGateway extends ActivityGateway {
|
|||||||
.query(ActivityDocument, { projectId, issueId: issueId ?? null })
|
.query(ActivityDocument, { projectId, issueId: issueId ?? null })
|
||||||
.pipe(map((d) => d.activity.map(toActivity)));
|
.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()
|
@Injectable()
|
||||||
|
|||||||
@@ -2358,6 +2358,25 @@ export type WriteReleaseNotesMutation = {
|
|||||||
writeReleaseNotes: { id: string; key: string | null; version: number };
|
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 = {
|
export type SavedFilterFieldsFragment = {
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -14144,6 +14163,154 @@ export const WriteReleaseNotesDocument = {
|
|||||||
WriteReleaseNotesMutation,
|
WriteReleaseNotesMutation,
|
||||||
WriteReleaseNotesMutationVariables
|
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 = {
|
export const SavedFiltersDocument = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
definitions: [
|
definitions: [
|
||||||
|
|||||||
@@ -72,3 +72,9 @@ mutation WriteReleaseNotes($releaseId: ID!, $notes: String) {
|
|||||||
...CommandResultFields
|
...CommandResultFields
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
query ActivityOfDay($projectId: ID!, $after: String!, $before: String!) {
|
||||||
|
activity(projectId: $projectId, after: $after, before: $before, limit: 200) {
|
||||||
|
...ActivityFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ import {
|
|||||||
HostListener,
|
HostListener,
|
||||||
inject,
|
inject,
|
||||||
signal,
|
signal,
|
||||||
|
TemplateRef,
|
||||||
untracked,
|
untracked,
|
||||||
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { DrawerService } from '@task-stream/web/shared/ui';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import {
|
import {
|
||||||
PiNoteComponent,
|
PiNoteComponent,
|
||||||
@@ -24,6 +27,7 @@ import {
|
|||||||
provideInsightsFacade,
|
provideInsightsFacade,
|
||||||
} from '@task-stream/web/tracker/application';
|
} from '@task-stream/web/tracker/application';
|
||||||
import {
|
import {
|
||||||
|
Activity,
|
||||||
ProjectRole,
|
ProjectRole,
|
||||||
QA_FAILED,
|
QA_FAILED,
|
||||||
StatusCategory,
|
StatusCategory,
|
||||||
@@ -72,6 +76,57 @@ const SLIDE_SECONDS = 15;
|
|||||||
providers: [provideInsightsFacade()],
|
providers: [provideInsightsFacade()],
|
||||||
host: { '[class.is-screen]': 'screen()' },
|
host: { '[class.is-screen]': 'screen()' },
|
||||||
template: `
|
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()) {
|
@if (screen()) {
|
||||||
<header class="ts-screen-bar">
|
<header class="ts-screen-bar">
|
||||||
<span class="ts-screen-bar__project">
|
<span class="ts-screen-bar__project">
|
||||||
@@ -192,7 +247,11 @@ const SLIDE_SECONDS = 15;
|
|||||||
[journey]="reading.journey"
|
[journey]="reading.journey"
|
||||||
[workflow]="facade.workflow()"
|
[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-backlog-health [health]="reading.backlog" />
|
||||||
<ts-quality
|
<ts-quality
|
||||||
[quality]="reading.quality"
|
[quality]="reading.quality"
|
||||||
@@ -312,6 +371,51 @@ const SLIDE_SECONDS = 15;
|
|||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
styles: `
|
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 {
|
.ts-screen-open {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -614,6 +718,48 @@ export class DashboardPage {
|
|||||||
protected readonly facade = inject(ProjectFacade);
|
protected readonly facade = inject(ProjectFacade);
|
||||||
protected readonly insights = inject(InsightsFacade);
|
protected readonly insights = inject(InsightsFacade);
|
||||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
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);
|
private readonly document = inject(DOCUMENT);
|
||||||
|
|
||||||
/* --- Modo pantalla: for a team's TV, a block at a time, rotating --- */
|
/* --- Modo pantalla: for a team's TV, a block at a time, rotating --- */
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
input,
|
input,
|
||||||
|
output,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
|
||||||
const WEEKDAYS = [
|
const WEEKDAYS = [
|
||||||
@@ -55,6 +56,16 @@ const MONTHS = [
|
|||||||
}}</span>
|
}}</span>
|
||||||
@for (week of weeks(); track week.start) {
|
@for (week of weeks(); track week.start) {
|
||||||
@if (week.days[row]; as day) {
|
@if (week.days[row]; as day) {
|
||||||
|
@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
|
<span
|
||||||
class="ts-heat__day"
|
class="ts-heat__day"
|
||||||
[attr.data-level]="day.level"
|
[attr.data-level]="day.level"
|
||||||
@@ -64,6 +75,7 @@ const MONTHS = [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<p class="ts-empty ts-heat__note">
|
<p class="ts-empty ts-heat__note">
|
||||||
@if (total()) {
|
@if (total()) {
|
||||||
@@ -116,6 +128,21 @@ const MONTHS = [
|
|||||||
outline: 1px dashed var(--ts-line);
|
outline: 1px dashed var(--ts-line);
|
||||||
outline-offset: -1px;
|
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 {
|
.ts-heat__note {
|
||||||
margin-top: 0.875rem;
|
margin-top: 0.875rem;
|
||||||
@@ -127,6 +154,10 @@ export class ActivityHeatmapComponent {
|
|||||||
readonly days = input.required<{ day: string; count: number }[]>();
|
readonly days = input.required<{ day: string; count: number }[]>();
|
||||||
/** Today (`YYYY-MM-DD`), to grey out the rest of this week. */
|
/** Today (`YYYY-MM-DD`), to grey out the rest of this week. */
|
||||||
readonly today = input<string>(new Date().toISOString().slice(0, 10));
|
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(() =>
|
protected readonly total = computed(() =>
|
||||||
this.days().reduce((sum, d) => sum + d.count, 0),
|
this.days().reduce((sum, d) => sum + d.count, 0),
|
||||||
@@ -139,7 +170,13 @@ export class ActivityHeatmapComponent {
|
|||||||
const weeks: {
|
const weeks: {
|
||||||
start: string;
|
start: string;
|
||||||
month: 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++) {
|
for (let w = 0; w * 7 < days.length; w++) {
|
||||||
const week = days.slice(w * 7, w * 7 + 7);
|
const week = days.slice(w * 7, w * 7 + 7);
|
||||||
@@ -162,6 +199,8 @@ export class ActivityHeatmapComponent {
|
|||||||
days: week.map((d) => {
|
days: week.map((d) => {
|
||||||
const future = d.day > this.today();
|
const future = d.day > this.today();
|
||||||
return {
|
return {
|
||||||
|
date: d.day,
|
||||||
|
count: d.count,
|
||||||
future,
|
future,
|
||||||
level: d.count ? Math.min(4, Math.ceil((d.count / max) * 4)) : 0,
|
level: d.count ? Math.min(4, Math.ceil((d.count / max) * 4)) : 0,
|
||||||
title: future
|
title: future
|
||||||
|
|||||||
Reference in New Issue
Block a user