import { afterRenderEffect, ChangeDetectionStrategy, Component, computed, DestroyRef, DOCUMENT, effect, ElementRef, HostListener, inject, signal, TemplateRef, untracked, viewChild, } from '@angular/core'; import { DrawerService } from '@task-stream/web/shared/ui'; import { RouterLink } from '@angular/router'; import { PiNoteComponent, PiSkeletonComponent, PiStatCardComponent, } from '@pi/ui'; import { InsightsFacade, ProjectFacade, provideInsightsFacade, } from '@task-stream/web/tracker/application'; import { Activity, ProjectRole, QA_FAILED, StatusCategory, TeamLoad, } from '@task-stream/web/tracker/domain'; import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy'; import { AgingComponent } from '../ui/insights/aging'; import { BottlenecksComponent } from '../ui/insights/bottlenecks'; import { RisksComponent } from '../ui/insights/risks'; import { SprintPulseComponent } from '../ui/insights/sprint-pulse'; import { ReleaseForecastComponent } from '../ui/insights/release-forecast'; import { ChangesSinceComponent } from '../ui/insights/changes-since'; import { BacklogHealthComponent } from '../ui/insights/backlog-health'; import { takeLastVisit } from '../ui/last-visit'; import { QualityComponent } from '../ui/insights/quality'; import { FieldBreakdownComponent } from '../ui/insights/field-breakdown'; 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', changeDetection: ChangeDetectionStrategy.OnPush, imports: [ RouterLink, PiNoteComponent, PiSkeletonComponent, PiStatCardComponent, SprintPulseComponent, RisksComponent, AgingComponent, BottlenecksComponent, ReleaseForecastComponent, ChangesSinceComponent, BacklogHealthComponent, QualityComponent, FieldBreakdownComponent, ActivityHeatmapComponent, JourneyComponent, ProposalsFunnelComponent, ], providers: [provideInsightsFacade()], host: { '[class.is-screen]': 'screen()' }, template: ` @if (insights.day(); as d) { @if (!d.entries) { } @else if (d.error) { } @else {

{{ d.entries.length }} {{ d.entries.length === 1 ? 'movimiento' : 'movimientos' }} @if (d.entries.length >= 200) { (los 200 últimos) } · {{ dayTickets().length }} {{ dayTickets().length === 1 ? 'ticket' : 'tickets' }}

@for (ticket of dayTickets(); track ticket.key) {
{{ ticket.key }}
    @for (entry of ticket.entries; track entry.id) {
  • {{ entry.summary }}
  • }
} @if (dayOthers().length) {

Del proyecto

    @for (entry of dayOthers(); track entry.id) {
  • {{ entry.summary }}
  • }
} } }
@if (screen()) {
{{ facade.project()?.key }} {{ facade.project()?.name }} @for (n of slideNumbers(); track n) { } En directo
} @else {
} @if (facade.stats(); as stats) {
@for (kpi of kpis(); track kpi.label) { }

Flujo

Dónde está cada ticket del proyecto
@if (stats.total) { } @else {

Todavía no hay tickets.

}
@if (insights.error(); as error) { } @else if (insights.insights(); as reading) {
@if (reading.changes; as changes) { } @if (reading.sprint; as sprint) { } @if (reading.releases.length) { } @if (reading.proposals; as proposals) { } @if (reading.fields.length) { }
} @else { }

Equipo

{{ team().length }} {{ team().length === 1 ? 'persona' : 'personas' }} · tickets abiertos

Por tipo

Todos los tickets
    @for (row of stats.byType; track row.key) {
  • {{ typeOf(row.key).label }} {{ row.count }}
  • }
} @else { } `, 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; 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; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: 1.25rem; margin-bottom: 2rem; } .ts-panel { padding: 1.5rem; } .ts-panel__head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.125rem; h2 { margin: 0; font-size: 1.125rem; font-weight: 700; } .ts-muted { font-size: 0.8125rem; } } .ts-flow { margin-bottom: 1.25rem; } .ts-flow__bar { display: flex; gap: 3px; height: 2.25rem; } .ts-flow__segment { flex-basis: 0; min-width: 0.5rem; border-radius: 0.375rem; background: var(--tone); } .ts-flow__legend { display: flex; flex-wrap: wrap; gap: 0.5rem 1.5rem; margin: 1rem 0 0; padding: 0; list-style: none; li { display: flex; align-items: baseline; gap: 0.4375rem; margin: 0; font-size: 0.8125rem; color: var(--ts-ink-soft); &::before { content: ''; align-self: center; width: 0.5rem; height: 0.5rem; border-radius: 2px; background: var(--tone); } } li.is-empty { opacity: 0.45; } } .ts-flow__count { font-family: var(--ts-font-mono); font-weight: 600; color: var(--ts-ink); } .ts-insights, .ts-insights-error, .ts-insights-loading { display: grid; margin-bottom: 1.25rem; } .ts-dashboard { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr)); gap: 1.25rem; } .ts-rows { display: grid; gap: 0.75rem; margin: 0; padding: 0; list-style: none; li { display: grid; grid-template-columns: 1.75rem minmax(6rem, 9rem) 1fr 2rem; align-items: center; gap: 0.625rem; margin: 0; font-size: 0.875rem; } } .ts-rows--types li { grid-template-columns: minmax(6rem, 9rem) 1fr 2rem; } .ts-rows__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ts-rows__count { font-family: var(--ts-font-mono); font-weight: 600; text-align: right; } .ts-who { display: inline-grid; place-items: center; width: 1.75rem; height: 1.75rem; border-radius: 50%; background: color-mix(in srgb, var(--ts-brand) 16%, var(--ts-surface)); color: color-mix(in srgb, var(--ts-brand) 70%, var(--ts-ink)); font-family: var(--ts-font-mono); font-size: 0.625rem; font-weight: 600; } .ts-who--none { background: transparent; border: 1px dashed var(--ts-line-strong); color: var(--ts-muted); } /* Team: everyone in the project, busiest first; each row opens their board. */ .ts-team { display: grid; margin: 0 -0.5rem; padding: 0; list-style: none; li { margin: 0; } } .ts-team__row { display: grid; grid-template-columns: 1.75rem minmax(0, 1fr) minmax(4rem, 8rem) 2rem 1rem; align-items: center; gap: 0.75rem; min-height: 2.75rem; padding: 0.375rem 0.5rem; border-radius: 0.75rem; color: inherit; font-size: 0.875rem; text-decoration: none; transition: background-color 0.15s ease; } a.ts-team__row:hover { background: var(--pi-color-primary-25); .ts-team__go { opacity: 1; transform: none; } } a.ts-team__row:focus-visible { outline: 2px solid var(--ts-brand); outline-offset: 1px; } .ts-team__who { display: flex; align-items: center; flex-wrap: wrap; gap: 0.25rem 0.5rem; min-width: 0; } .ts-team__load { flex-basis: 100%; color: var(--ts-muted); font-size: 0.75rem; } .ts-pill--warn { --tone: var(--ts-c-red, #d14343); } .ts-team__go { color: var(--ts-brand); opacity: 0; transform: translateX(-3px); transition: opacity 0.15s ease, transform 0.15s ease; } @media (max-width: 520px) { /* On a phone the number says enough: give the room to name and role. */ .ts-team__row { grid-template-columns: 1.75rem minmax(0, 1fr) 2rem 1rem; } .ts-team__row .ts-bar { display: none; } } .ts-rows__count.is-zero { color: var(--ts-muted); font-weight: 400; } .ts-bar { height: 0.625rem; border-radius: 0.25rem; background: var(--ts-sunken); overflow: hidden; span { display: block; height: 100%; border-radius: 0.25rem; background: var(--ts-brand); } } .ts-bar--tone span { background: var(--tone); } `, }) export class DashboardPage { protected readonly facade = inject(ProjectFacade); protected readonly insights = inject(InsightsFacade); private readonly host = inject>(ElementRef); protected readonly drawer = inject(DrawerService); private readonly dayPanel = viewChild.required>('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(); 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 --- */ 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 | 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; this.openedFor = projectId; untracked(() => this.insights.open(projectId, takeLastVisit(projectId))); }); } protected readonly initialsOf = initials; private count(status: string): number { return ( this.facade.stats()?.byStatus.find((s) => s.key === status)?.count ?? 0 ); } /** Tickets in statuses of a category (whatever the project calls them). */ private countCategory(category: StatusCategory): number { const workflow = this.facade.workflow(); return (this.facade.stats()?.byStatus ?? []) .filter((row) => workflow.status(row.key).category === category) .reduce((sum, row) => sum + row.count, 0); } protected readonly kpis = computed< { label: string; value: number; icon: string; color: 'primary' | 'accent' | 'warn' | 'highlighted' | 'success'; }[] >(() => { const stats = this.facade.stats(); if (!stats) return []; // Bugs: every type drawn with the bug icon. const workflow = this.facade.workflow(); const bugs = stats.byType .filter((t) => workflow.type(t.key).icon === 'bug') .reduce((sum, t) => sum + t.count, 0); return [ { label: 'Tickets', value: stats.total, icon: 'list', color: 'primary' }, { label: 'Abiertos', value: stats.open, icon: 'time-detail', color: 'accent', }, { label: 'Bugs (total)', value: bugs, icon: 'bug', color: 'warn' }, { label: 'En QA', value: this.countCategory(StatusCategory.QA), icon: 'qa', color: 'highlighted', }, { label: 'QA fallidos', value: this.count(QA_FAILED), icon: 'exclamation', color: 'warn', }, { label: 'Hechos', value: this.countCategory(StatusCategory.DONE), icon: 'check-rounded', color: 'success', }, ]; }); /** * The project's statuses in board order, cancelled ones last. Archived (or * no longer configured) statuses only while tickets still sit in them. */ protected readonly flow = computed(() => { const workflow = this.facade.workflow(); const configured = workflow.settings.statuses.map((s) => s.id); const ids = [ ...configured, ...(this.facade.stats()?.byStatus ?? []) .map((row) => row.key) .filter((key) => !configured.includes(key)), ]; const cancelled = (id: string) => workflow.status(id).category === StatusCategory.CANCELLED; return [...ids.filter((id) => !cancelled(id)), ...ids.filter(cancelled)] .map((status) => ({ status, ...statusLook(workflow, status), archived: workflow.status(status).archived, count: this.count(status), })) .filter((row) => !row.archived || row.count > 0); }); protected readonly flowLabel = computed(() => this.flow() .filter((s) => s.count) .map((s) => `${s.label}: ${s.count}`) .join(', '), ); protected readonly maxLoad = computed(() => Math.max( 1, ...(this.facade.stats()?.byAssignee.map((r) => r.count) ?? [1]), ), ); /** * Every member with their open tickets (0 included), busiest first. People * who still have tickets but left the project are listed too. */ protected readonly team = computed(() => { const load = new Map(); for (const row of this.facade.stats()?.byAssignee ?? []) { if (row.user) load.set(row.user.id, { name: row.user.name, count: row.count }); } const members = this.facade.members().map((member) => ({ id: member.user.id, name: member.user.name, role: member.role as ProjectRole | null, open: load.get(member.user.id)?.count ?? 0, })); const former = [...load.entries()] .filter(([id]) => !members.some((m) => m.id === id)) .map(([id, { name, count }]) => ({ id, name, role: null, open: count, })); return [...members, ...former].sort( (a, b) => b.open - a.open || a.name.localeCompare(b.name, 'es'), ); }); /** Open points and usual pace per person (from the insights). */ protected readonly loadOf = computed( () => new Map( (this.insights.insights()?.team ?? []).map((t) => [t.user.id, t]), ), ); protected loadText(load: TeamLoad): string { const parts = [`${load.openPoints} pts abiertos`]; if (load.pointsPerSprint !== null) parts.push( `cierra ${load.pointsPerSprint.toLocaleString('es')} por sprint`, ); if (load.blocked) parts.push( `${load.blocked} ${load.blocked === 1 ? 'bloqueado' : 'bloqueados'}`, ); return parts.join(' · '); } protected readonly unassigned = computed( () => this.facade.stats()?.byAssignee.find((row) => !row.user)?.count ?? 0, ); protected readonly roleLabel = (role: ProjectRole) => ROLE[role]; protected readonly roleTone = (role: ProjectRole) => ROLE_TONE[role]; protected readonly typeOf = (key: string) => typeLook(this.facade.workflow(), key); }