import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
untracked,
} from '@angular/core';
import { PiEmptyStateComponent, PiSkeletonComponent } from '@pi/ui';
import {
provideSharedDashboardFacade,
SharedDashboardFacade,
} from '@task-stream/web/tracker/application';
import { tone } from '../ui/copy';
import { ActivityHeatmapComponent } from '../ui/insights/activity-heatmap';
import { ReleaseForecastComponent } from '../ui/insights/release-forecast';
import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
/**
* /share/:token — a project's dashboard for its clients, without an
* account: only the blocks its owners chose, read again every minute.
*/
@Component({
selector: 'ts-shared-dashboard-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
PiEmptyStateComponent,
PiSkeletonComponent,
SprintPulseComponent,
ReleaseForecastComponent,
ActivityHeatmapComponent,
],
template: `
@if (shared.missing()) {
} @else if (shared.dashboard(); as d) {
@if (d.flow; as flow) {
Flujo
{{ total() }} tickets
@if (total()) {
@for (s of flow; track s.status) {
@if (s.count) {
}
}
@for (s of flow; track s.status) {
-
{{ s.count }}
{{ s.name }}
}
} @else {
Todavía no hay tickets.
}
}
@if (d.blocks.includes('sprint')) {
@if (d.sprint; as sprint) {
} @else {
Sprint
No hay un sprint en curso.
}
}
@if (d.releases; as releases) {
@if (releases.length) {
} @else {
Próximas releases
Ninguna planificada.
}
}
@if (d.epics; as epics) {
Épicas
Abiertas, por fecha
@if (epics.length) {
@for (e of epics; track e.key) {
-
{{ e.title }}
{{ e.done }}/{{ e.total }}
@if (e.dueDate) {
· {{ date(e.dueDate) }}
}
}
} @else {
Ninguna épica abierta.
}
}
@if (d.activity; as activity) {
}
} @else if (shared.error(); as error) {
} @else {
}
`,
styles: `
:host {
display: grid;
gap: 1.5rem;
}
.ts-shared {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 24rem), 1fr));
gap: 1.25rem;
align-items: start;
}
.ts-shared__wide {
grid-column: 1 / -1;
}
.ts-panel {
padding: 1.5rem;
}
.ts-panel__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1rem;
h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 700;
}
.ts-muted {
font-size: 0.8125rem;
}
}
.ts-shared__title {
margin: 0 0 0.5rem;
font-size: 1.125rem;
font-weight: 700;
}
.ts-flow__bar {
display: flex;
gap: 3px;
height: 2rem;
span {
flex-basis: 0;
border-radius: 0.375rem;
background: var(--tone);
}
}
.ts-flow__legend {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.25rem;
margin: 0.875rem 0 0;
padding: 0;
list-style: none;
font-size: 0.875rem;
li {
display: inline-flex;
align-items: center;
gap: 0.375rem;
&::before {
content: '';
width: 0.625rem;
height: 0.625rem;
border-radius: 0.1875rem;
background: var(--tone);
}
&.is-empty {
color: var(--ts-muted);
}
}
}
.ts-flow__count {
font-family: var(--ts-font-mono);
font-weight: 600;
}
.ts-epics {
display: grid;
gap: 0.75rem;
margin: 0;
padding: 0;
list-style: none;
li {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.25rem 0.75rem;
}
}
.ts-epics__title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.9375rem;
}
.ts-epics__meta {
color: var(--ts-muted);
font-family: var(--ts-font-mono);
font-size: 0.75rem;
}
.ts-epics__bar {
grid-column: 1 / -1;
height: 0.5rem;
border-radius: 0.25rem;
background: var(--ts-sunken);
overflow: hidden;
span {
display: block;
height: 100%;
background: var(--ts-brand);
}
}
.ts-shared__footer {
margin: 0;
color: var(--ts-muted);
font-size: 0.75rem;
text-align: center;
}
`,
providers: [provideSharedDashboardFacade()],
})
export class SharedDashboardPage {
/** Route param (`withComponentInputBinding`). */
readonly token = input.required();
protected readonly shared = inject(SharedDashboardFacade);
protected readonly total = computed(() =>
(this.shared.dashboard()?.flow ?? []).reduce((n, s) => n + s.count, 0),
);
protected readonly flowLabel = computed(() =>
(this.shared.dashboard()?.flow ?? [])
.map((s) => `${s.name}: ${s.count}`)
.join(', '),
);
constructor() {
effect(() => {
const token = this.token();
untracked(() => this.shared.open(token));
});
}
protected readonly toneOf = tone;
protected time(iso: string): string {
return new Date(iso).toLocaleTimeString('es', {
hour: '2-digit',
minute: '2-digit',
});
}
protected date(day: string): string {
return new Date(`${day.slice(0, 10)}T00:00:00Z`).toLocaleDateString('es', {
day: 'numeric',
month: 'short',
timeZone: 'UTC',
});
}
}