feat(web): the project dashboard on the team's TV
«Modo pantalla» puts the dashboard full screen, over the rest of the app: one block at a time and larger (the figures and the flow first), moving on every 15 seconds, with the project, the time and the live data. ← and → move, the space bar pauses and Esc leaves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -297,7 +297,7 @@ 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, 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». Debajo, mis proyectos con su actividad |
|
||||
@@ -308,7 +308,7 @@ visualización de la plataforma:
|
||||
| `/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 |
|
||||
| `/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), 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 |
|
||||
|
||||
@@ -128,3 +128,5 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
|
||||
- Panel del proyecto, cuarta parte: actividad de 12 semanas en un mapa de calor, el recorrido de un ticket (dónde se
|
||||
va el tiempo de creado a hecho, espera incluida, y la parte que es trabajo) y el embudo de propuestas del público
|
||||
con las más votadas sin respuesta.
|
||||
- Modo pantalla del panel: a pantalla completa para una tele del equipo, un bloque cada vez que cambia solo, en
|
||||
directo y con la hora.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,9 +1,15 @@
|
||||
import {
|
||||
afterRenderEffect,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
DOCUMENT,
|
||||
effect,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
inject,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
@@ -38,6 +44,9 @@ import { ActivityHeatmapComponent } from '../ui/insights/activity-heatmap';
|
||||
import { JourneyComponent } from '../ui/insights/journey';
|
||||
import { ProposalsFunnelComponent } from '../ui/insights/proposals-funnel';
|
||||
|
||||
/** Seconds each block stays in «Modo pantalla». */
|
||||
const SLIDE_SECONDS = 15;
|
||||
|
||||
/** "Seguimiento": the state of the whole project at a glance (live). */
|
||||
@Component({
|
||||
selector: 'ts-dashboard-page',
|
||||
@@ -61,7 +70,48 @@ import { ProposalsFunnelComponent } from '../ui/insights/proposals-funnel';
|
||||
ProposalsFunnelComponent,
|
||||
],
|
||||
providers: [provideInsightsFacade()],
|
||||
host: { '[class.is-screen]': 'screen()' },
|
||||
template: `
|
||||
@if (screen()) {
|
||||
<header class="ts-screen-bar">
|
||||
<span class="ts-screen-bar__project">
|
||||
<strong>{{ facade.project()?.key }}</strong>
|
||||
{{ facade.project()?.name }}
|
||||
</span>
|
||||
<span class="ts-screen-bar__dots">
|
||||
@for (n of slideNumbers(); track n) {
|
||||
<button
|
||||
type="button"
|
||||
[class.is-current]="n === current()"
|
||||
[attr.aria-label]="'Ir al bloque ' + (n + 1)"
|
||||
(click)="go(n)"
|
||||
></button>
|
||||
}
|
||||
</span>
|
||||
<span class="ts-screen-bar__live">
|
||||
<span class="ts-screen-bar__dot" aria-hidden="true"></span>
|
||||
En directo
|
||||
</span>
|
||||
<time class="ts-screen-bar__clock">{{ clock() }}</time>
|
||||
<button type="button" class="ts-plain-link" (click)="togglePause()">
|
||||
{{ paused() ? 'Seguir' : 'Pausa' }}
|
||||
</button>
|
||||
<button type="button" class="ts-plain-link" (click)="exitScreen()">
|
||||
Salir
|
||||
</button>
|
||||
</header>
|
||||
} @else {
|
||||
<div class="ts-screen-open">
|
||||
<button
|
||||
type="button"
|
||||
class="ts-plain-link"
|
||||
title="A pantalla completa, un bloque cada vez, para una tele del equipo"
|
||||
(click)="enterScreen()"
|
||||
>
|
||||
Modo pantalla
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (facade.stats(); as stats) {
|
||||
<section class="ts-kpis" aria-label="Resumen">
|
||||
@for (kpi of kpis(); track kpi.label) {
|
||||
@@ -262,6 +312,89 @@ import { ProposalsFunnelComponent } from '../ui/insights/proposals-funnel';
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.ts-screen-open {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
/* Modo pantalla: over everything, one block at a time, larger. */
|
||||
:host(.is-screen) {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
display: block;
|
||||
overflow: auto;
|
||||
padding: 1.25rem 2.5rem 2.5rem;
|
||||
background: var(--ts-bg);
|
||||
}
|
||||
:host(.is-screen) .ts-dashboard,
|
||||
:host(.is-screen) .ts-insights {
|
||||
display: block;
|
||||
}
|
||||
:host(.is-screen) .ts-slide-off {
|
||||
display: none !important;
|
||||
}
|
||||
:host(.is-screen) .ts-slide-on {
|
||||
max-width: 64rem;
|
||||
margin: 0 auto 1.25rem;
|
||||
zoom: 1.5;
|
||||
}
|
||||
.ts-screen-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
.ts-screen-bar__project {
|
||||
margin-right: auto;
|
||||
white-space: nowrap;
|
||||
strong {
|
||||
font-family: var(--ts-font-mono);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
.ts-screen-bar__dots {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
/* Plain dots: past the app's button styles. */
|
||||
button {
|
||||
all: unset;
|
||||
width: 0.625rem !important;
|
||||
min-width: 0 !important;
|
||||
height: 0.625rem !important;
|
||||
padding: 0 !important;
|
||||
border-radius: 50%;
|
||||
background: var(--ts-line-strong);
|
||||
cursor: pointer;
|
||||
&.is-current {
|
||||
background: var(--ts-brand);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--ts-brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ts-screen-bar__live {
|
||||
display: inline-flex;
|
||||
white-space: nowrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ts-screen-bar__dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--ts-c-green);
|
||||
}
|
||||
.ts-screen-bar__clock {
|
||||
font-family: var(--ts-font-mono);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Same stat row as pireon.dev's hero. */
|
||||
.ts-kpis {
|
||||
display: grid;
|
||||
@@ -480,11 +613,130 @@ import { ProposalsFunnelComponent } from '../ui/insights/proposals-funnel';
|
||||
export class DashboardPage {
|
||||
protected readonly facade = inject(ProjectFacade);
|
||||
protected readonly insights = inject(InsightsFacade);
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
|
||||
/* --- Modo pantalla: for a team's TV, a block at a time, rotating --- */
|
||||
protected readonly screen = signal(false);
|
||||
protected readonly paused = signal(false);
|
||||
private readonly slide = signal(0);
|
||||
private readonly slides = signal(0);
|
||||
protected readonly current = computed(() =>
|
||||
this.slides() ? this.slide() % this.slides() : 0,
|
||||
);
|
||||
protected readonly slideNumbers = computed(() =>
|
||||
Array.from({ length: this.slides() }, (_, n) => n),
|
||||
);
|
||||
protected readonly clock = signal('');
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private ticks = 0;
|
||||
|
||||
private readonly marks = afterRenderEffect(() => {
|
||||
this.screen();
|
||||
this.current();
|
||||
this.insights.insights();
|
||||
this.markSlides();
|
||||
});
|
||||
|
||||
protected enterScreen(): void {
|
||||
this.screen.set(true);
|
||||
this.paused.set(false);
|
||||
this.slide.set(0);
|
||||
this.updateClock();
|
||||
this.host.nativeElement.requestFullscreen?.().catch(() => undefined);
|
||||
this.timer = setInterval(() => this.tick(), 1000);
|
||||
}
|
||||
|
||||
protected exitScreen(): void {
|
||||
if (!this.screen()) return;
|
||||
this.screen.set(false);
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
if (this.document.fullscreenElement)
|
||||
this.document.exitFullscreen().catch(() => undefined);
|
||||
}
|
||||
|
||||
protected togglePause(): void {
|
||||
this.paused.update((p) => !p);
|
||||
this.ticks = 0;
|
||||
}
|
||||
|
||||
protected go(n: number): void {
|
||||
this.slide.set(n);
|
||||
this.ticks = 0;
|
||||
}
|
||||
|
||||
/** Every second: the clock, and every 15 s (unless paused) the next block. */
|
||||
private tick(): void {
|
||||
this.updateClock();
|
||||
if (this.paused()) return;
|
||||
if (++this.ticks >= SLIDE_SECONDS) {
|
||||
this.ticks = 0;
|
||||
this.slide.update((n) => n + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private updateClock(): void {
|
||||
this.clock.set(
|
||||
new Date().toLocaleTimeString('es', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The blocks to rotate: the figures and the flow together first, then
|
||||
* every panel on its own. Shown or hidden by a class, so the live
|
||||
* updates keep landing on the same elements.
|
||||
*/
|
||||
private markSlides(): void {
|
||||
const root = this.host.nativeElement;
|
||||
const groups: Element[][] = [];
|
||||
const lead = [
|
||||
...root.querySelectorAll(':scope > .ts-kpis, :scope > .ts-flow'),
|
||||
];
|
||||
if (lead.length) groups.push(lead);
|
||||
root
|
||||
.querySelectorAll(':scope > .ts-dashboard > *')
|
||||
.forEach((panel) => groups.push([panel]));
|
||||
this.slides.set(groups.length);
|
||||
const on = this.screen();
|
||||
groups.forEach((group, n) =>
|
||||
group.forEach((el) => {
|
||||
el.classList.toggle('ts-slide-on', on && n === this.current());
|
||||
el.classList.toggle('ts-slide-off', on && n !== this.current());
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
protected onKey(event: KeyboardEvent): void {
|
||||
if (!this.screen()) return;
|
||||
if (event.key === 'ArrowRight') this.go(this.current() + 1);
|
||||
else if (event.key === 'ArrowLeft')
|
||||
this.go(
|
||||
(this.current() - 1 + this.slides()) % Math.max(1, this.slides()),
|
||||
);
|
||||
else if (event.key === ' ') this.togglePause();
|
||||
else if (event.key === 'Escape') this.exitScreen();
|
||||
else return;
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
/** Leaving the browser's full screen (Esc) leaves the mode too. */
|
||||
@HostListener('document:fullscreenchange')
|
||||
protected onFullscreenChange(): void {
|
||||
if (!this.document.fullscreenElement) this.exitScreen();
|
||||
}
|
||||
|
||||
/** Opened once per project: the project itself changes on every update. */
|
||||
private openedFor: string | null = null;
|
||||
|
||||
constructor() {
|
||||
inject(DestroyRef).onDestroy(() => {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
});
|
||||
effect(() => {
|
||||
const projectId = this.facade.project()?.id;
|
||||
if (!projectId || projectId === this.openedFor) return;
|
||||
|
||||
Reference in New Issue
Block a user