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