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:
2026-10-16 09:41:59 +02:00
parent 1101b6e926
commit 4dc673997d
5 changed files with 272 additions and 18 deletions

View File

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